converse.js
Version:
Browser based XMPP chat client
1,081 lines (1,011 loc) • 38.6 kB
JavaScript
/**
* @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
* @typedef {import('@converse/headless/shared/types').MessageAttributes} MessageAttributes
* @typedef {import('@converse/headless/plugins/muc/types').MUCMessageAttributes} MUCMessageAttributes
* @typedef {import('@converse/headless').ChatBox} ChatBox
* @typedef {import('@converse/headless/types/shared/message').default} BaseMessage
*/
import { html } from "lit";
import { __ } from "i18n";
import { until } from "lit/directives/until.js";
import { _converse, converse, api, log, u, constants, MUC } from "@converse/headless";
import tplAudio from "shared/texture/templates/audio.js";
import tplFile from "templates/file.js";
import tplImage from "shared/texture/templates/image.js";
import tplVideo from "shared/texture/templates/video.js";
import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from "./consts.js";
import { MIMETYPES_MAP } from "utils/file.js";
import { IQError, UserFacingError } from "shared/errors.js";
import DeviceLists from "./devicelists.js";
import {getFileName} from "utils/html.js";
import {Texture} from "shared/texture/texture.js";
const { Strophe, sizzle, stx } = converse.env;
const { CHATROOMS_TYPE, PRIVATE_CHAT_TYPE } = constants;
const {
appendArrayBuffer,
arrayBufferToBase64,
arrayBufferToHex,
arrayBufferToString,
base64ToArrayBuffer,
hexToArrayBuffer,
initStorage,
isAudioURL,
isError,
isImageURL,
isVideoURL,
stringToArrayBuffer,
} = u;
/**
* @param {string} fp
*/
export function formatFingerprint(fp) {
fp = fp.replace(/^05/, "");
for (let i = 1; i < 8; i++) {
const idx = i * 8 + i - 1;
fp = fp.slice(0, idx) + " " + fp.slice(idx);
}
return fp;
}
/**
* @param {string} fp
*/
export function formatFingerprintForQRCode(fp) {
const sid = _converse.state.omemo_store.get("device_id");
const jid = _converse.session.get("bare_jid");
fp = fp.replace(/^05/, "");
return `xmpp:${jid}?omemo-sid-${sid}=${fp}`;
}
/**
* @param {Error|IQError|UserFacingError} e
* @param {ChatBox} chat
*/
export function handleMessageSendError(e, chat) {
if (e instanceof 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 UserFacingError) {
api.alert("error", __("Error"), [e.message]);
}
throw e;
}
/**
* @param {string} jid
*/
export async function contactHasOMEMOSupport(jid) {
/* Checks whether the contact advertises any OMEMO-compatible devices. */
const devices = await getDevicesForContact(jid);
return devices.length > 0;
}
/**
* @param {ChatBox|MUC} chat
* @param {MessageAttributes} attrs
* @return {MessageAttributes}
*/
export function getOutgoingMessageAttributes(chat, attrs) {
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;
}
/**
* @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, 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: appendArrayBuffer(exported_key, tag),
payload: arrayBufferToBase64(ciphertext),
iv: arrayBufferToBase64(iv),
};
}
/**
* @param {import('./types').EncryptedMessage} obj
* @returns {Promise<string>}
*/
async function decryptMessage(obj) {
const key_obj = await crypto.subtle.importKey("raw", obj.key, KEY_ALGO, true, ["encrypt", "decrypt"]);
const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag);
const algo = /** @type {AesGcmParams} */ {
name: "AES-GCM",
iv: base64ToArrayBuffer(obj.iv),
tagLength: TAG_LENGTH,
};
return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
}
/**
* @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);
const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified });
Object.assign(encrypted_file, { xep454_ivkey: arrayBufferToHex(iv) + arrayBufferToHex(exported_key) });
return encrypted_file;
}
/**
* @param {import('@converse/headless/types/shared/message').default} message
* @param {import('@converse/headless/shared/types').FileUploadMessageAttributes} attrs
*/
export function setEncryptedFileURL(message, attrs) {
const url = attrs.oob_url.replace(/^https?:/, "aesgcm:") + "#" + message.file.xep454_ivkey;
return Object.assign(attrs, {
oob_url: null, // Since only the body gets encrypted, we don't set the oob_url
message: url,
body: url,
});
}
/**
* @param {string} iv
* @param {string} key
* @param {ArrayBuffer} cipher
*/
async function decryptFile(iv, key, cipher) {
const key_obj = await crypto.subtle.importKey("raw", hexToArrayBuffer(key), "AES-GCM", false, ["decrypt"]);
const algo = /** @type {AesGcmParams} */ {
name: "AES-GCM",
iv: hexToArrayBuffer(iv),
};
return crypto.subtle.decrypt(algo, key_obj, cipher);
}
/**
* @param {string} url
* @returns {Promise<ArrayBuffer|null>}
*/
async function downloadFile(url) {
let response;
try {
response = await fetch(url);
} catch (e) {
log.error(`${e.name}: Failed to download encrypted media: ${url}`);
log.error(e);
return null;
}
if (response.status >= 200 && response.status < 400) {
return response.arrayBuffer();
}
}
/**
* @param {string} url_text
* @returns {Promise<string|Error|null>}
*/
async function getAndDecryptFile(url_text) {
const url = new URL(url_text);
const protocol = window.location.hostname === "localhost" && url.hostname === "localhost" ? "http" : "https";
const http_url = url.toString().replace(/^aesgcm/, protocol);
const cipher = await downloadFile(http_url);
if (cipher === null) {
log.error(`Could not decrypt a received encrypted file ${url.toString()} since it could not be downloaded`);
return new Error(__("Error: could not decrypt a received encrypted file, because it could not be downloaded"));
}
const hash = url.hash.slice(1);
const key = hash.substring(hash.length - 64);
const iv = hash.replace(key, "");
let content;
try {
content = await decryptFile(iv, key, cipher);
} catch (e) {
log.error(`Could not decrypt file ${url.toString()}`);
log.error(e);
return null;
}
const [filename, extension] = url.pathname.split("/").pop().split(".");
const mimetype = MIMETYPES_MAP[extension];
try {
const file = new File([content], filename, { "type": mimetype });
return URL.createObjectURL(file);
} catch (e) {
log.error(`Could not decrypt file ${url.toString()}`);
log.error(e);
return null;
}
}
/**
* @param {string} file_url
* @param {string|Error} obj_url
* @param {Texture} richtext
* @returns {import("lit").TemplateResult}
*/
function getTemplateForObjectURL(file_url, obj_url, richtext) {
if (isError(obj_url)) {
return html`<p class="error">${/** @type {Error} */(obj_url).message}</p>`;
}
if (isImageURL(file_url)) {
return tplImage({
src: obj_url,
onClick: richtext.onImgClick,
onLoad: richtext.onImgLoad,
});
} else if (isAudioURL(file_url)) {
return tplAudio(/** @type {string} */(obj_url));
} else if (isVideoURL(file_url)) {
return tplVideo(/** @type {string} */(obj_url));
} else {
return tplFile(obj_url, getFileName(file_url));
}
}
/**
* @param {string} text
* @param {number} offset
* @param {import('shared/texture/texture.js').Texture} richtext
*/
function addEncryptedFiles(text, offset, richtext) {
const objs = [];
try {
const parse_options = { start: /\b(aesgcm:\/\/)/gi };
u.withinString(
text,
/**
* @param {string} url
* @param {number} start
* @param {number} end
*/
(url, start, end) => {
objs.push({ url, start, end });
return url;
},
parse_options
);
} catch (error) {
log.debug(error);
return;
}
objs.forEach((o) => {
const promise = getAndDecryptFile(o.url).then((obj_url) => getTemplateForObjectURL(o.url, obj_url, richtext));
const template = html`${until(promise, "")}`;
richtext.addTemplateResult(o.start + offset, o.end + offset, template);
});
}
/**
* @param {import('shared/texture/texture.js').Texture} richtext
*/
export function handleEncryptedFiles(richtext) {
if (!_converse.state.config.get("trusted")) {
return;
}
richtext.addAnnotations(
/**
* @param {string} text
* @param {number} offset
*/
(text, offset) => addEncryptedFiles(text, offset, richtext)
);
}
/**
* Hook handler for {@link parseMessage} and {@link parseMUCMessage}, which
* parses the passed in `message` stanza for OMEMO attributes and then sets
* them on the attrs object.
* @param {Element} stanza - The message stanza
* @param {MUCMessageAttributes|MessageAttributes} attrs
* @returns {Promise<MUCMessageAttributes| MessageAttributes|
import('./types').MUCMessageAttrsWithEncryption|import('./types').MessageAttrsWithEncryption>}
*/
export async function parseEncryptedMessage(stanza, attrs) {
if (
api.settings.get("clear_cache_on_logout") ||
!attrs.is_encrypted ||
attrs.encryption_namespace !== Strophe.NS.OMEMO
) {
return 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, {
error_condition: "not-encrypted-for-this-device",
error_type: "Decryption",
is_ephemeral: true,
is_error: true,
type: "error",
});
}
// https://xmpp.org/extensions/xep-0384.html#usecases-receiving
if (attrs.encrypted.prekey === true) {
return decryptPrekeyWhisperMessage(attrs);
} else {
return decryptWhisperMessage(attrs);
}
}
export function onChatBoxesInitialized() {
_converse.state.chatboxes.on("add", (chatbox) => {
checkOMEMOSupported(chatbox);
if (chatbox.get("type") === 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();
});
}
/**
* @param {string} jid
* @param {number} id
*/
export function getSessionCipher(jid, id) {
const { libsignal } = /** @type WindowWithLibsignal */ (window);
const address = new libsignal.SignalProtocolAddress(jid, id);
return new libsignal.SessionCipher(_converse.state.omemo_store, address);
}
/**
* We use the bare, real (i.e. non-MUC) JID as encrypted session identifier.
* @param {MUCMessageAttributes|MessageAttributes} attrs
*/
function getJIDForDecryption(attrs) {
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 ('from_real_jid' in attrs) {
from_jid = 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;
}
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 omemo.decryptMessage(Object.assign(encrypted, { key, tag }));
device.save("active", true);
return result;
}
}
function getDecryptionErrorAttributes(e) {
return {
"error_text":
__("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
"error_condition": e.name,
"error_message": e.message,
"error_type": "Decryption",
"is_ephemeral": true,
"is_error": true,
"type": "error",
};
}
/**
* @param {MUCMessageAttributes|MessageAttributes} attrs
*/
async function decryptPrekeyWhisperMessage(attrs) {
const from_jid = getJIDForDecryption(attrs);
const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
const key = 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);
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));
}
}
/**
* @param {MUCMessageAttributes|MessageAttributes} attrs
*/
async function decryptWhisperMessage(attrs) {
const from_jid = getJIDForDecryption(attrs);
const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
const key = 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));
}
}
/**
* Given an XML element representing a user's OMEMO bundle, parse it
* and return a map.
* @param {Element} bundle_el
* @returns {import('./types').Bundle}
*/
export function parseBundle(bundle_el) {
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,
};
}
/**
* @param {string} jid
*/
export async function generateFingerprints(jid) {
const devices = await getDevicesForContact(jid);
return Promise.all(devices.map((d) => generateFingerprint(d)));
}
/**
* @param {import('./device.js').default} device
*/
export async function generateFingerprint(device) {
if (device.get("bundle")?.fingerprint) {
return;
}
const bundle = await device.getBundle();
bundle["fingerprint"] = arrayBufferToHex(base64ToArrayBuffer(bundle["identity_key"]));
device.save("bundle", bundle);
device.trigger("change:bundle"); // Doesn't get triggered automatically due to pass-by-reference
}
/**
* @param {string} jid
* @returns {Promise<import('./devices.js').default>}
*/
export async function getDevicesForContact(jid) {
await api.waitUntil("OMEMOInitialized");
const devicelist = await api.omemo.devicelists.get(jid, true);
await devicelist.fetchDevices();
return devicelist.devices;
}
/**
* @param {string} jid
* @param {string} device_id
* @returns {Promise<import('./device.js').default[]>}
*/
export async function getDeviceForContact(jid, device_id) {
const devices = await getDevicesForContact(jid);
return devices.get(device_id);
}
export async function generateDeviceID() {
const { libsignal } = /** @type WindowWithLibsignal */ (window);
/* Generates a device ID, making sure that it's unique */
const bare_jid = _converse.session.get("bare_jid");
const devicelist = await api.omemo.devicelists.get(bare_jid, true);
const existing_ids = devicelist.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();
}
/**
* @param {import('./device.js').default} device
*/
async function buildSession(device) {
const { libsignal } = /** @type WindowWithLibsignal */ (window);
const address = new libsignal.SignalProtocolAddress(device.get("jid"), device.get("id"));
const sessionBuilder = new libsignal.SessionBuilder(_converse.state.omemo_store, address);
const prekey = device.getRandomPreKey();
const bundle = await device.getBundle();
return sessionBuilder.processPreKey({
registrationId: parseInt(device.get("id"), 10),
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.js').default} device
*/
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 { libsignal } = /** @type WindowWithLibsignal */ (window);
const address = new libsignal.SignalProtocolAddress(device.get("jid"), device.get("id"));
const session = await _converse.state.omemo_store.loadSession(address.toString());
if (session) {
return session;
} else {
try {
return await buildSession(device);
} catch (e) {
log.error(`Could not build an OMEMO session for device ${device.get("id")}`);
log.error(e);
return null;
}
}
}
/**
* @param {Element} stanza
*/
async 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 = await api.omemo.devicelists.get(jid, true);
const device = devicelist.devices.get(device_id) || devicelist.devices.create({ "id": device_id, jid });
device.save({ "bundle": parseBundle(bundle_el) });
}
/**
* @param {Element} stanza
*/
async 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 = await api.omemo.devicelists.get(jid, true);
const devices = devicelist.devices;
const removed_ids = devices.pluck("id").filter((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} 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",
"headline"
);
}
export async function restoreOMEMOSession() {}
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();
},
});
});
// Call API method to wait for our own device list to be fetched from the
// server or to be created. If we have no pre-existing OMEMO session, this
// will cause a new device and bundle to be generated and published.
await api.omemo.devicelists.get(bare_jid, true);
}
/**
* @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 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 {MUC} chatroom
* @param {import('@converse/headless/types/plugins/muc/occupant').default} occupant
*/
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 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") === CHATROOMS_TYPE) {
await api.waitUntil("OMEMOInitialized");
supported = chatbox.features.get("nonanonymous") && chatbox.features.get("membersonly");
} else if (chatbox.get("type") === PRIVATE_CHAT_TYPE) {
supported = await contactHasOMEMOSupport(chatbox.get("jid"));
}
chatbox.set("omemo_supported", !!supported);
if (supported && api.settings.get("omemo_default")) {
chatbox.set("omemo_active", true);
}
}
/**
* @param {MouseEvent} ev
*/
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") === 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") });
}
/**
* @param {import('shared/chat/toolbar').ChatToolbar} toolbar_el
* @param {Array<import('lit').TemplateResult>} buttons
*/
export function getOMEMOToolbarButton(toolbar_el, buttons) {
const model = toolbar_el.model;
const is_muc = model.get("type") === CHATROOMS_TYPE;
let title;
if (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 if (is_muc) {
title = __(
"This groupchat needs to be members-only and non-anonymous in " +
"order to support OMEMO encrypted messages"
);
} else {
title = __("OMEMO encryption is not supported");
}
let color;
if (model.get("omemo_supported")) {
if (model.get("omemo_active")) {
color = is_muc ? `var(--muc-color)` : `var(--chat-color)`;
} else {
color = `var(--error-color)`;
}
} else {
color = `var(--disabled-color)`;
}
buttons.push(html`
<button
type="button"
class="btn toggle-omemo"
title="${title}"
data-disabled=${!model.get("omemo_supported")}
@click=${toggleOMEMO}
>
<converse-icon
class="fa ${model.get("omemo_active") ? `fa-lock` : `fa-unlock`}"
path-prefix="${api.settings.get("assets_path")}"
size="1em"
color="${color}"
></converse-icon>
</button>
`);
return buttons;
}
/**
* @param {MUC|ChatBox} chatbox
* @returns {Promise<import('./device.js').default[]>}
*/
async function getBundlesAndBuildSessions(chatbox) {
const no_devices_err = __("Sorry, no devices found to which we can send an OMEMO encrypted message.");
let devices;
if (chatbox instanceof MUC) {
const collections = await Promise.all(
chatbox.occupants.map(
/** @param {import('@converse/headless/types/plugins/muc/occupant').default} o */
(o) => getDevicesForContact(o.get("jid"))
)
);
devices = collections.reduce((a, b) => a.concat(b.models), []);
} else if (chatbox.get("type") === PRIVATE_CHAT_TYPE) {
const their_devices = await getDevicesForContact(chatbox.get("jid"));
if (their_devices.length === 0) {
throw new UserFacingError(no_devices_err);
}
const bare_jid = _converse.session.get("bare_jid");
const own_list = await api.omemo.devicelists.get(bare_jid);
const own_devices = own_list.devices;
devices = [...own_devices.models, ...their_devices.models];
}
// Filter out our own device
const id = _converse.state.omemo_store.get("device_id");
devices = devices.filter(/** @param {import('./device.js').default} d */ (d) => d.get("id") !== id);
// Fetch bundles if necessary
await Promise.all(devices.map((d) => d.getBundle()));
const sessions = devices.filter((d) => d).map((d) => getSession(d));
await Promise.all(sessions);
if (sessions.includes(null)) {
// We couldn't build a session for certain devices.
devices = devices.filter((d) => sessions[devices.indexOf(d)]);
if (devices.length === 0) {
throw new UserFacingError(no_devices_err);
}
}
return devices;
}
/**
* @param {ArrayBuffer} key_and_tag
* @param {import('./device.js').default} device
*/
function encryptKey(key_and_tag, device) {
return getSessionCipher(device.get("jid"), device.get("id"))
.encrypt(key_and_tag)
.then((payload) => ({ payload, device }));
}
/**
* @param {MUC|ChatBox} chat
* @param {{ message: BaseMessage, stanza: import('strophe.js').Builder }} data
* @return {Promise<{ message: BaseMessage, stanza: import('strophe.js').Builder }>}
*/
export async function createOMEMOMessageStanza(chat, data) {
let { stanza } = data;
const { message } = data;
if (!message.get("is_encrypted")) {
return data;
}
if (!message.get("body")) {
throw new Error("No message body to encrypt!");
}
const devices = await getBundlesAndBuildSessions(chat);
const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get("plaintext"));
// The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as
// devices associated with the contact, the result of this
// concatenation is encrypted using the corresponding
// long-standing SignalProtocol session.
const dicts = await Promise.all(
devices
.filter((device) => device.get("trusted") != UNTRUSTED && device.get("active"))
.map((device) => encryptKey(key_and_tag, device))
);
// 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.
stanza
.cnode(
stx`
<encrypted xmlns="${Strophe.NS.OMEMO}">
<header sid="${_converse.state.omemo_store.get("device_id")}">
${dicts.map(({ payload, device }) => {
const prekey = 3 == parseInt(payload.type, 10);
if (prekey) {
return stx`<key rid="${device.get("id")}" prekey="true">${btoa(payload.body)}</key>`;
}
return stx`<key rid="${device.get("id")}">${btoa(payload.body)}</key>`;
})}
<iv>${iv}</iv>
</header>
<payload>${payload}</payload>
</encrypted>`
)
.root();
stanza.cnode(stx`<store xmlns="${Strophe.NS.HINTS}"/>`).root();
stanza.cnode(stx`<encryption xmlns="${Strophe.NS.EME}" namespace="${Strophe.NS.OMEMO}"/>`).root();
return { message, stanza };
}
export const omemo = {
decryptMessage,
encryptMessage,
formatFingerprint,
};