@converse/headless
Version:
Converse.js Headless build
581 lines (531 loc) • 24.3 kB
JavaScript
/**
* @typedef {import('../../shared/types').MessageAttributes} MessageAttributes
* @typedef {import('../../plugins/muc/types').MUCMessageAttributes} MUCMessageAttributes
* @typedef {import('./types').MUCMessageAttrsWithEncryption} MUCMessageAttrsWithEncryption
* @typedef {import('./types').MessageAttrsWithEncryption} MessageAttrsWithEncryption
*/
import sizzle from 'sizzle';
import log from '@converse/log';
import api from '../../shared/api/index.js';
import _converse from '../../shared/_converse.js';
import converse from '../../shared/api/public.js';
import u from '../../utils/index.js';
import { decryptMessage, getSessionCipher, sendOMEMOHeartbeat } from './utils.js';
import { getCrypto } from './crypto.js';
import { VersionedOMEMOStore } from './versioned-store.js';
import { decryptSCE } from './sce.js';
import {
getChatMarker,
getChatState,
getFallbackAttributes,
getOutOfBandAttributes,
getReferences,
getReplyAttributes,
getSpoilerAttributes,
} from '../../shared/parsers.js';
const { Strophe } = converse.env;
// XEP-0384: "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."
const HEARTBEAT_COUNTER_THRESHOLD = 53;
// Synchronous guard to prevent concurrent heartbeat sends for the same session.
// Two decryptions arriving in the same tick could both pass the store.loadHeartbeatKey
// check before either storeHeartbeatKey() completes. This Set is checked and mutated
// synchronously (no await), so it closes that window. The store check still handles
// page reloads (the Set is cleared on reload).
const heartbeat_in_flight = new Set();
const DECRYPTION_ERROR_ATTRS = {
error_type: 'Decryption',
is_ephemeral: true,
// Don't start the ephemeral auto-destruct countdown until the message has
// actually been seen by the user (handled in the view).
defer_ephemeral_timer: true,
is_error: true,
type: 'error',
};
/**
* @returns {object}
*/
function getNoKeyErrorAttrs() {
const { __ } = _converse;
return {
...DECRYPTION_ERROR_ATTRS,
error_condition: 'not-encrypted-for-this-device',
error_text: __(
'Received an OMEMO encrypted message which could not be decrypted, ' +
'because it was not encrypted for this device.',
),
};
}
/**
* @param {Error} e
*/
function getDecryptionErrorAttributes(e) {
const { __ } = _converse;
return {
...DECRYPTION_ERROR_ATTRS,
error_text:
__('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
error_condition: e.name,
error_message: e.message,
};
}
/**
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @param {Error} e
*/
function handleDecryptionError(attrs, e) {
if (e?.name === 'MessageCounterError') {
// XEP-0384: a message we already decrypted. Ignore it silently.
log.debug(`Ignoring a duplicate OMEMO message: ${e.message}`);
return attrs;
}
log.error(`OMEMO decryption failed: ${e.name} ${e.message}`);
return Object.assign(attrs, getDecryptionErrorAttributes(e));
}
/**
* We use the bare, real (i.e. non-MUC) JID as encrypted session identifier.
* @param {MUCMessageAttributes|MessageAttributes} attrs
*/
function getJIDForDecryption(attrs) {
const { __ } = _converse;
let from_jid;
if (attrs.sender === 'me') {
from_jid = _converse.session.get('bare_jid');
} else if (attrs.contact_jid) {
from_jid = attrs.contact_jid;
} else if (attrs.type === 'groupchat') {
// MUC message: from_real_jid is the occupant's real JID, set by the MUC parser.
from_jid = /** @type {MUCMessageAttributes} */ (attrs).from_real_jid;
} else {
from_jid = attrs.from;
}
if (!from_jid) {
Object.assign(attrs, {
error_text: __(
'Sorry, could not decrypt a received OMEMO ' +
"message because we don't have the XMPP address for that user.",
),
error_type: 'Decryption',
is_ephemeral: true,
is_error: true,
type: 'error',
});
throw new Error('Could not find JID to decrypt OMEMO message for');
}
return from_jid;
}
/**
* Implements the XEP-0384 heartbeat rule: when we've just decrypted the first
* message for a given ratchet key whose counter is >= 53, send a heartbeat (an
* empty OMEMO message) to forward the ratchet. We send at most one heartbeat per
* ratchet key; the dedup is persisted in the (versioned) OMEMO store so it
* survives page reloads (the peer only restarts its counter at 0 a round-trip
* after processing our heartbeat). Best-effort and fire-and-forget — failures
* are logged, never surfaced to the user.
*
* The legacy heartbeat is a `KeyTransportElement` (XEP-0384 0.3.0), which the
* older protocol defines and conforming clients already handle.
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @param {string} from_jid - the (real) bare JID identifying the OMEMO session
* @param {string|number} device_id - the sender's device id
* @param {{counter: number, key: ArrayBuffer}|undefined} ratchet - from the decrypt result
* @param {import('./types').OMEMOVersion} version
*/
async function maybeSendOMEMOHeartbeat(attrs, from_jid, device_id, ratchet, version) {
if (!ratchet || ratchet.counter < HEARTBEAT_COUNTER_THRESHOLD) return;
const { OMEMOAddress } = await getCrypto();
const address = new OMEMOAddress(from_jid, parseInt(`${device_id}`, 10)).toString();
const ratchet_key_b64 = u.arrayBufferToBase64(ratchet.key);
if (heartbeat_in_flight.has(address)) return;
heartbeat_in_flight.add(address);
try {
const store = new VersionedOMEMOStore(_converse.state.omemo_store, version);
if (store.loadHeartbeatKey(address) === ratchet_key_b64) return;
// Send to the MUC room (groupchat) or the contact (1:1). The session is
// keyed by the sender's real JID, but the heartbeat is addressed to the chat.
// For MUC messages, attrs.from is the room JID and attrs.type is 'groupchat'.
// For 1:1, from_jid is the contact's bare JID (the chatbox key).
const chat_jid = attrs.type === 'groupchat' ? Strophe.getBareJidFromJid(attrs.from) : from_jid;
const chat = _converse.state.chatboxes?.get(chat_jid);
if (!chat) return;
await sendOMEMOHeartbeat(chat, version);
await store.storeHeartbeatKey(address, ratchet_key_b64);
} finally {
heartbeat_in_flight.delete(address);
}
}
/**
* Fire-and-forget wrapper around {@link maybeSendOMEMOHeartbeat}. Logs errors
* instead of letting them propagate to the caller.
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @param {string} from_jid - the (real) bare JID identifying the OMEMO session
* @param {string|number} device_id - the sender's device id
* @param {{counter: number, key: ArrayBuffer}|undefined} ratchet - from the decrypt result
* @param {import('./types').OMEMOVersion} version
*/
function fireHeartbeat(attrs, from_jid, device_id, ratchet, version) {
maybeSendOMEMOHeartbeat(attrs, from_jid, device_id, ratchet, version).catch((e) =>
log.error(`Could not send OMEMO heartbeat: ${e}`),
);
}
/**
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @param {ArrayBuffer} key_and_tag
*/
async function handleDecryptedWhisperMessage(attrs, key_and_tag) {
const from_jid = getJIDForDecryption(attrs);
const devicelist = await api.omemo.devicelists.get(from_jid, true);
const encrypted = attrs.encrypted;
let device = devicelist.devices.get(encrypted.device_id);
if (!device) {
device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true });
}
if (encrypted.payload) {
const key = key_and_tag.slice(0, 16);
const tag = key_and_tag.slice(16);
const result = await decryptMessage({
...encrypted,
payload: encrypted.payload,
...{ key, tag },
});
device.save('active', true);
return result;
}
}
/**
* @param {MUCMessageAttributes|MessageAttributes} attrs
*/
async function decryptWhisperMessage(attrs) {
const from_jid = getJIDForDecryption(attrs);
const session_cipher = await getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
const key = u.base64ToArrayBuffer(attrs.encrypted.key);
try {
const { plaintext: key_and_tag, ratchet } = await session_cipher.decryptWhisperMessage(key, 'binary');
const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
fireHeartbeat(attrs, from_jid, attrs.encrypted.device_id, ratchet, Strophe.NS.OMEMO);
if (plaintext) {
return Object.assign(attrs, { plaintext });
} else {
// Empty/heartbeat message (KeyTransportElement with no <payload>).
return Object.assign(attrs, { 'is_only_key': true });
}
} catch (e) {
return handleDecryptionError(attrs, e);
}
}
/**
* @param {MUCMessageAttributes|MessageAttributes} attrs
*/
async function decryptPrekeyWhisperMessage(attrs) {
const from_jid = getJIDForDecryption(attrs);
const session_cipher = await getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
const key = u.base64ToArrayBuffer(attrs.encrypted.key);
let key_and_tag, ratchet;
try {
({ plaintext: key_and_tag, ratchet } = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary'));
} catch (e) {
return handleDecryptionError(attrs, e);
}
fireHeartbeat(attrs, from_jid, attrs.encrypted.device_id, ratchet, Strophe.NS.OMEMO);
try {
const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
const { omemo_store } = _converse.state;
await omemo_store.generateMissingPreKeys();
await 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));
}
}
/**
* Decrypt a legacy OMEMO (eu.siacs.conversations.axolotl) message.
* @param {Element} stanza
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @returns {Promise<MUCMessageAttributes|MessageAttributes|MUCMessageAttrsWithEncryption|MessageAttrsWithEncryption>}
*/
async function decryptLegacyOMEMOMessage(stanza, attrs) {
const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
const header = encrypted_el.querySelector('header');
attrs.encrypted = { 'device_id': header.getAttribute('sid') };
const device_id = await api.omemo?.getDeviceID();
const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop();
if (key) {
Object.assign(attrs.encrypted, {
iv: header.querySelector('iv').textContent,
key: key.textContent,
payload: encrypted_el.querySelector('payload')?.textContent || null,
prekey: ['true', '1'].includes(key.getAttribute('prekey')),
});
} else {
return Object.assign(attrs, getNoKeyErrorAttrs());
}
// https://xmpp.org/extensions/xep-0384.html#usecases-receiving
if (attrs.encrypted.prekey === true) {
return decryptPrekeyWhisperMessage(attrs);
} else {
return decryptWhisperMessage(attrs);
}
}
/**
* Decrypt an OMEMO 2 message.
* @param {Element} stanza
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @param {import('../muc/muc.js').default} [chatbox] - The MUC model (for re-parsing reactions)
* @returns {Promise<MUCMessageAttributes|MessageAttributes|MUCMessageAttrsWithEncryption|MessageAttrsWithEncryption>}
*/
async function decryptOMEMO2Message(stanza, attrs, chatbox) {
const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO2}"]`, stanza).pop();
if (!encrypted_el) return attrs;
const header = encrypted_el.querySelector('header');
const sender_device_id = header.getAttribute('sid');
const device_id = await api.omemo?.getDeviceID();
if (!device_id) return attrs;
// Find our <key rid='...'> under any <keys jid='our_bare_jid'>
const bare_jid = _converse.session.get('bare_jid');
const keys_el = sizzle(`keys[jid="${bare_jid}"]`, encrypted_el).pop();
if (!keys_el) {
return Object.assign(attrs, getNoKeyErrorAttrs());
}
const key_el = keys_el.querySelector(`key[rid="${device_id}"]`);
if (!key_el) {
return Object.assign(attrs, getNoKeyErrorAttrs());
}
const from_jid = getJIDForDecryption(attrs);
const key_b64 = key_el.textContent.trim();
const key_bytes = u.base64ToArrayBuffer(key_b64);
// Whether the <key> holds a key exchange (OMEMOKeyExchange) or a regular
// message (OMEMOAuthenticatedMessage). We detect this from the payload
// rather than trusting the `kex` attribute: libsignal-based senders (e.g.
// QXmpp/Kaidan) keep emitting key-exchange messages until they receive a
// reply, but only set kex="true" on the first one, leaving the repeats
// mislabelled. The two protobufs are unambiguous by their first tag byte —
// OMEMOKeyExchange begins with field 1 `pk_id` (varint → 0x08), whereas
// OMEMOAuthenticatedMessage begins with field 1 `mac` (bytes → 0x0A).
const is_kex = new Uint8Array(key_bytes)[0] === 0x08 || key_el.getAttribute('kex') === 'true';
attrs.encrypted = {
device_id: sender_device_id,
key: key_b64,
payload: encrypted_el.querySelector('payload')?.textContent?.trim() || null,
prekey: is_kex,
};
const session_cipher = await getSessionCipher(from_jid, parseInt(sender_device_id, 10), Strophe.NS.OMEMO2);
let key_and_tag, ratchet;
try {
if (is_kex) {
({ plaintext: key_and_tag, ratchet } = await session_cipher.decryptPreKeyWhisperMessage(
key_bytes,
'binary',
));
// After handling a key exchange, regenerate and publish prekeys
const { omemo_store } = _converse.state;
await omemo_store.generateMissingPreKeys();
await omemo_store.publishBundle();
} else {
({ plaintext: key_and_tag, ratchet } = await session_cipher.decryptWhisperMessage(key_bytes, 'binary'));
}
} catch (e) {
return handleDecryptionError(attrs, e);
}
fireHeartbeat(attrs, from_jid, sender_device_id, ratchet, Strophe.NS.OMEMO2);
if (!attrs.encrypted.payload) {
// Empty/heartbeat message
return Object.assign(attrs, { 'is_only_key': true });
}
try {
const is_muc = attrs.type === 'groupchat';
const muc_jid = is_muc ? attrs.from : null;
const { body, content } = await decryptSCE(key_and_tag, attrs.encrypted.payload, {
sender_jid: from_jid,
to_jid: muc_jid,
});
// Update device active state
const devicelist = await api.omemo.devicelists.get(from_jid, true, Strophe.NS.OMEMO2);
let device = devicelist.devices.get(sender_device_id);
if (!device) {
device = await devicelist.devices.create({ 'id': sender_device_id, 'jid': from_jid }, { 'promise': true });
}
device.save('active', true);
if (!content) {
// Payload present but no <content> at all — nothing to surface.
return Object.assign(attrs, { 'is_only_key': true });
}
// A payload-bearing SCE envelope is never a heartbeat (those carry no
// <payload> and are handled above). Its <content> may hold a <body>
// with body-coupled metadata (references/reply/oob/spoiler), or it may
// be a metadata-only message (chat state / marker / reaction) with no
// <body>. Either way we re-run the normal parsers against the decrypted,
// authenticated <content> — not the wire stanza, which carries none of
// this for an encrypted message.
Object.assign(
attrs,
{ references: getReferences(content) },
getReplyAttributes(content),
getFallbackAttributes(content),
getOutOfBandAttributes(content),
getSpoilerAttributes(content),
);
if (body) attrs.plaintext = body;
// The chat state now travels encrypted inside <content> (it's gated out
// of the cleartext stanza for encrypted messages), so we read it from
// there. Chat state and markers are only applied when actually present,
// so a metadata-only message (e.g. a reaction) gets no spurious chat
// state and any cleartext one from a non-SCE sender isn't clobbered.
const chat_state = getChatState(content);
if (chat_state) attrs.chat_state = chat_state;
const marker = getChatMarker(content);
if (marker) {
attrs.is_marker = true;
attrs.marker_id = marker.getAttribute('id');
}
if (sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, content).length) {
attrs.is_markable = true;
}
/**
* *Hook* which lets plugins parse metadata from the decrypted SCE
* `<content>` of an OMEMO:2 message, the same way they parse a wire
* stanza via `parseMessage`/`parseMUCMessage`. This keeps plugin-specific
* parsing (e.g. XEP-0444 reactions) in the plugin instead of coupling it
* to OMEMO. The `chatbox` is forwarded so MUC-aware parsers can resolve
* room context.
* @event _converse#parseEncryptedContent
* @param {Element} content - The decrypted SCE `<content>` element
* @param {object} attrs - The message attributes parsed so far
* @param {import('../muc/muc.js').default} [chatbox] - The MUC model, if any
* @example api.listen.on('parseEncryptedContent', parseReactionsMessage);
*/
attrs = await api.hook('parseEncryptedContent', content, attrs, chatbox);
// A content that surfaced nothing the user acts on (no body and no
// chat state / marker / reaction) is treated as a key-transport message
// so it isn't shown as a blank message. `reaction_to_id` is added by the
// reactions plugin via the hook above, so it's not on the base type.
const has_reaction = !!(/** @type {{reaction_to_id?: string}} */ (attrs)).reaction_to_id;
if (has_reaction) {
// The emoji body is only a legacy-OMEMO fallback (marked by a
// XEP-0428 <fallback for="urn:xmpp:reactions:0">); the structured
// <reactions> is what we act on. Drop it so the emoji isn't surfaced
// as a standalone message alongside the reaction.
delete (/** @type {{plaintext?: string}} */ (attrs)).plaintext;
}
if (!body && !attrs.chat_state && !attrs.is_marker && !attrs.is_markable && !has_reaction) {
return Object.assign(attrs, { 'is_only_key': true });
}
return attrs;
} catch (e) {
log.error(`SCE decryption failed: ${e.name} ${e.message}`);
return Object.assign(attrs, getDecryptionErrorAttributes(e));
}
}
/**
* Hook handler for {@link parseMessage} and {@link parseMUCMessage}, which
* parses the passed in `message` stanza for OMEMO attributes and then sets
* them on the attrs object.
*
* A single stanza may carry both an `urn:xmpp:omemo:2` and a legacy
* `eu.siacs.conversations.axolotl` `<encrypted>` element: a sender that
* supports both addresses each recipient device in whichever version that
* device understands. The EME (XEP-0380) hint names only one method and exists
* for clients that can decrypt *neither* — it must not be used to pick a
* decryption path. So we route on which `<encrypted>` block actually contains a
* `<key>` for our own device, preferring omemo:2.
*
* @param {Element} stanza - The message stanza
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @param {import('../muc/muc.js').default} [chatbox] - The MUC model (only for
* `parseMUCMessage`); used to re-parse encrypted reactions from the SCE content.
* @returns {Promise<MUCMessageAttributes| MessageAttributes|MUCMessageAttrsWithEncryption|MessageAttrsWithEncryption>}
*/
export async function parseEncryptedMessage(stanza, attrs, chatbox) {
if (api.settings.get('clear_cache_on_logout') || !attrs.is_encrypted) {
return attrs;
}
const device_id = await api.omemo?.getDeviceID();
const bare_jid = _converse.session.get('bare_jid');
const v2_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO2}"]`, stanza).pop();
const legacy_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
const has_v2_key =
!!(v2_el && device_id) && sizzle(`keys[jid="${bare_jid}"] key[rid="${device_id}"]`, v2_el).length > 0;
const has_legacy_key = !!(legacy_el && device_id) && sizzle(`key[rid="${device_id}"]`, legacy_el).length > 0;
if (has_v2_key) {
return await decryptOMEMO2Message(stanza, attrs, chatbox);
}
if (has_legacy_key) {
return await decryptLegacyOMEMOMessage(stanza, attrs);
}
// The message is OMEMO-encrypted but no <key> is addressed to this device.
// Only surface the "not encrypted for this device" error once we actually
// know our own device id, otherwise OMEMO isn't ready yet and we'd raise a
// spurious error during an init race.
if (device_id && (v2_el || legacy_el)) {
return Object.assign(attrs, getNoKeyErrorAttrs());
}
// Not an OMEMO message we can handle; leave attrs untouched so any EME
// fallback body is shown.
return attrs;
}
/**
* Given an XML element representing a legacy OMEMO bundle, parse it
* and return a map.
* @param {Element} [bundle_el]
* @returns {?import('./types').Bundle} `null` if no bundle element was given.
*/
export function parseBundle(bundle_el) {
if (!bundle_el) return null;
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(
/** @param {Element} el */ (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,
};
}
/**
* Given an XML element representing an OMEMO 2 bundle, parse it
* and return a map using the same internal format as the legacy bundle.
*
* All key values are base64-encoded 32-byte raw Curve25519/Ed25519 bytes
* (the leading 0x05 byte is absent for v2).
*
* @param {Element} [bundle_el]
* @returns {?import('./types').Bundle} `null` if no bundle element was given.
*/
export function parseBundleV2(bundle_el) {
if (!bundle_el) return null;
const spk_el = bundle_el.querySelector('spk');
const prekeys = sizzle('prekeys > pk', bundle_el).map(
/** @param {Element} el */ (el) => ({
id: parseInt(el.getAttribute('id'), 10),
key: el.textContent.trim(),
}),
);
return {
identity_key: bundle_el.querySelector('ik').textContent.trim(),
signed_prekey: {
id: parseInt(spk_el.getAttribute('id'), 10),
public_key: spk_el.textContent.trim(),
signature: bundle_el.querySelector('spks').textContent.trim(),
},
prekeys,
};
}
Object.assign(u, {
omemo: {
...u.omemo,
parseBundle,
parseBundleV2,
},
});