@converse/headless
Version:
Converse.js Headless build
232 lines (208 loc) • 10.2 kB
JavaScript
/**
* SCE (Stanza Content Encryption, XEP-0420) payload encryption for OMEMO 2.
*
* Implements the crypto layer required by XEP-0384 when using urn:xmpp:omemo:2:
* - AES-256-CBC + HMAC-SHA-256 authenticated encryption
* - HKDF-SHA-256 key derivation (80 bytes → encKey || authKey || IV)
* - The 48-byte tuple (key ‖ truncated-HMAC) is what each per-device
* SessionCipher.encrypt() receives
* - The SCE envelope wraps the plaintext XML in <envelope xmlns='urn:xmpp:sce:1'>
*/
import converse from '../../shared/api/public.js';
const { Strophe, u, stx, sizzle } = converse.env;
const HKDF_INFO = 'OMEMO Payload';
/**
* Constant-time comparison of two byte arrays. Unlike a short-circuiting
* compare (e.g. `Array.every`), the running time does not depend on where the
* first differing byte is, so it leaks no information about the expected value
* to a timing attacker. Used to verify the SCE HMAC tag.
*
* We hand-roll this rather than use `crypto.subtle.verify('HMAC', …)` (which
* compares internally) because SCE truncates the HMAC to 16 bytes, and
* `subtle.verify` only checks the full 32-byte SHA-256 output — there's no
* supported way to verify a truncation. Node's `crypto.timingSafeEqual` isn't
* an option either: it's absent in the browser, our production target.
* @param {Uint8Array} a
* @param {Uint8Array} b
* @returns {boolean}
*/
function constantTimeEqual(a, b) {
// Fold the length difference into the accumulator so unequal-length inputs
// can never compare equal, without an early return.
let diff = a.length ^ b.length;
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
}
return diff === 0;
}
/**
* Derive encKey, authKey, IV from a random 32-byte content key using HKDF-SHA-256.
* @param {ArrayBuffer} content_key - 32 random bytes
* @returns {Promise<{encKey: ArrayBuffer, authKey: ArrayBuffer, iv: ArrayBuffer}>}
*/
async function deriveKeys(content_key) {
const salt = new ArrayBuffer(32); // 32 zero bytes
const ikm = await crypto.subtle.importKey('raw', content_key, 'HKDF', false, ['deriveBits']);
const derived = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt,
info: new TextEncoder().encode(HKDF_INFO),
},
ikm,
640, // 80 bytes = 640 bits
);
return {
encKey: derived.slice(0, 32),
authKey: derived.slice(32, 64),
iv: derived.slice(64, 80),
};
}
/**
* Build the SCE <envelope> XML string for a given message body.
*
* Affixes per XEP-0384 SCE profile:
* - <rpad> MUST
* - <from> SHOULD (included)
* - <to> MUST for MUC (included when muc_jid is provided)
* - <time> MAY (omitted for now)
*
* The `extensions` are extra element builders (XEP-0372 references, XEP-0461
* reply, OOB url, spoiler) spliced into `<content>` right after `<body>`, so
* body-coupled metadata is encrypted alongside the body instead of leaking in
* cleartext. They live inside the authenticated `<content>` and are therefore
* covered by the SCE HMAC.
*
* `body` may be empty/null for a metadata-only message (e.g. an encrypted
* chat state, marker or reaction), in which case no `<body>` element is
* emitted and `<content>` carries only the `extensions`.
*
* @param {string|null} body - plaintext message body (omitted when falsy)
* @param {{from_jid: string, to_jid: string|null}} affixes
* @param {import('strophe.js').Builder[]} [extensions] - extra <content> children
* @returns {import('strophe.js').Builder}
*/
function buildSCEEnvelope(body, { from_jid, to_jid }, extensions = []) {
const rpad_len = 1 + Math.floor(Math.random() * 100);
const rpad = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(rpad_len))));
return stx`
<envelope xmlns="${Strophe.NS.SCE}">
<content>${body ? stx`<body xmlns="jabber:client">${body}</body>` : ''}${extensions}</content>
<rpad>${rpad}</rpad>
<from xmlns="${Strophe.NS.SCE}" jid="${from_jid}"/>
${to_jid ? stx`<to xmlns="${Strophe.NS.SCE}" jid="${to_jid}"/>` : ''}
</envelope>`;
}
/**
* Encrypt a message body using the SCE/OMEMO 2 scheme.
*
* Returns the 48-byte key_and_tag (content_key ‖ truncated-HMAC) as an
* ArrayBuffer — this is what gets encrypted with SessionCipher per device —
* and the base64-encoded AES-256-CBC ciphertext as the <payload> value.
*
* @param {string|null} plaintext - the message body to encrypt (omitted when falsy)
* @param {{from_jid: string, to_jid: string|null}} affixes
* @param {import('strophe.js').Builder[]} [extensions] - body-coupled metadata elements
* @returns {Promise<{key_and_tag: ArrayBuffer, payload: string}>}
*/
export async function encryptSCE(plaintext, affixes, extensions = []) {
// Random 32-byte content key
const content_key = crypto.getRandomValues(new Uint8Array(32)).buffer;
const { encKey, authKey, iv } = await deriveKeys(content_key);
const envelope = buildSCEEnvelope(plaintext, affixes, extensions);
const plaintext_bytes = new TextEncoder().encode(envelope.toString());
// AES-256-CBC encryption
const aes_key = await crypto.subtle.importKey('raw', encKey, 'AES-CBC', false, ['encrypt']);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aes_key, plaintext_bytes);
// HMAC-SHA-256 over ciphertext, truncated to 16 bytes
const hmac_key = await crypto.subtle.importKey('raw', authKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const hmac_full = await crypto.subtle.sign('HMAC', hmac_key, ciphertext);
const hmac_16 = hmac_full.slice(0, 16);
// 48-byte tuple: content_key(32) ‖ HMAC(16)
const key_and_tag = new Uint8Array(48);
key_and_tag.set(new Uint8Array(content_key), 0);
key_and_tag.set(new Uint8Array(hmac_16), 32);
return {
key_and_tag: key_and_tag.buffer,
payload: u.arrayBufferToBase64(ciphertext),
};
}
/**
* Decrypt an SCE/OMEMO 2 payload.
*
* Returns both the plaintext body string and the decrypted `<content>` element,
* so callers can re-run the normal stanza parsers (references/reply/oob/spoiler,
* but also chat states / markers / reactions) against the authenticated content.
* `content` is `null` only when the envelope carries no `<content>` element;
* `body` is `null` when there's no `<body>` — i.e. a metadata-only message.
*
* @param {ArrayBuffer} key_and_tag - 48-byte tuple: content_key(32) ‖ HMAC(16)
* @param {string} payload_b64 - base64-encoded AES-256-CBC ciphertext
* @param {{sender_jid: string, to_jid?: string|null}} expected_affixes - for validation
* @returns {Promise<{body: string|null, content: Element|null}>}
*/
export async function decryptSCE(key_and_tag, payload_b64, expected_affixes) {
if (key_and_tag.byteLength !== 48) {
throw new Error(`SCE key_and_tag must be 48 bytes, got ${key_and_tag.byteLength}`);
}
const content_key = key_and_tag.slice(0, 32);
const expected_hmac = key_and_tag.slice(32, 48);
const { encKey, authKey, iv } = await deriveKeys(content_key);
const ciphertext = u.base64ToArrayBuffer(payload_b64);
// Verify HMAC before decrypting
const hmac_key = await crypto.subtle.importKey('raw', authKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const hmac_full = await crypto.subtle.sign('HMAC', hmac_key, ciphertext);
const hmac_16 = new Uint8Array(hmac_full.slice(0, 16));
const expected = new Uint8Array(expected_hmac);
if (!constantTimeEqual(hmac_16, expected)) {
throw new Error('SCE HMAC verification failed');
}
// AES-256-CBC decrypt
const aes_key = await crypto.subtle.importKey('raw', encKey, 'AES-CBC', false, ['decrypt']);
const plaintext_bytes = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aes_key, ciphertext);
const envelope_xml = new TextDecoder().decode(plaintext_bytes);
const content = parseSCEEnvelope(envelope_xml, expected_affixes);
const body = content ? (sizzle('> body', content).pop()?.textContent ?? null) : null;
return { body, content };
}
/**
* Parse an SCE <envelope>, validate its affixes and return the decrypted
* `<content>` element (or `null` for a heartbeat with no `<content>`/`<body>`).
* @param {string} envelope_xml
* @param {{sender_jid: string, to_jid?: string|null}} expected_affixes
* @returns {Element|null}
*/
function parseSCEEnvelope(envelope_xml, { sender_jid, to_jid }) {
const parser = new DOMParser();
const doc = parser.parseFromString(envelope_xml, 'text/xml');
const envelope = doc.documentElement;
if (!envelope || envelope.localName !== 'envelope' || envelope.namespaceURI !== Strophe.NS.SCE) {
throw new Error('SCE: no <envelope> found in decrypted payload');
}
// Validate <from> affix (SHOULD be present, MUST match sender if present)
const from_el = sizzle(`> from[xmlns="${Strophe.NS.SCE}"]`, envelope).pop();
if (from_el) {
const from_jid = from_el.getAttribute('jid');
if (sender_jid && !u.isSameBareJID(from_jid, sender_jid)) {
throw new Error(`SCE affix mismatch: <from> is ${from_jid}, expected ${sender_jid}`);
}
}
// Validate <to> affix (MUST be present and correct for MUC)
if (to_jid) {
const to_el = sizzle(`> to[xmlns="${Strophe.NS.SCE}"]`, envelope).pop();
if (!to_el) {
throw new Error('SCE: missing required <to> affix for MUC message');
}
if (!u.isSameBareJID(to_el.getAttribute('jid'), to_jid)) {
throw new Error(`SCE affix mismatch: <to> is ${to_el.getAttribute('jid')}, expected ${to_jid}`);
}
}
// Return the <content> whenever it's present, even without a <body>. A
// payload-bearing SCE envelope with no body is a metadata-only message
// (chat state / marker / reaction), NOT a heartbeat — true OMEMO:2
// heartbeats carry no <payload> at all and never reach this code. The
// caller decides what to do with a bodyless content.
return sizzle('> content', envelope).pop() ?? null;
}