utils.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  1. /**
  2. * @typedef {module:plugins-omemo-index.WindowWithLibsignal} WindowWithLibsignal
  3. * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
  4. * @typedef {module:plugin-muc-parsers.MUCMessageAttributes} MUCMessageAttributes
  5. * @typedef {import('@converse/headless').ChatBox} ChatBox
  6. */
  7. import tplAudio from 'templates/audio.js';
  8. import tplFile from 'templates/file.js';
  9. import tplImage from 'templates/image.js';
  10. import tplVideo from 'templates/video.js';
  11. import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from './consts.js';
  12. import { MIMETYPES_MAP } from 'utils/file.js';
  13. import { __ } from 'i18n';
  14. import { _converse, converse, api, log } from '@converse/headless';
  15. import { html } from 'lit';
  16. import { initStorage } from '@converse/headless/utils/storage.js';
  17. import { isError } from '@converse/headless/utils/object.js';
  18. import { isAudioURL, isImageURL, isVideoURL, getURI } from '@converse/headless/utils/url.js';
  19. import { CHATROOMS_TYPE, PRIVATE_CHAT_TYPE } from '@converse/headless/shared/constants.js';
  20. import { until } from 'lit/directives/until.js';
  21. import {
  22. appendArrayBuffer,
  23. arrayBufferToBase64,
  24. arrayBufferToHex,
  25. arrayBufferToString,
  26. base64ToArrayBuffer,
  27. hexToArrayBuffer,
  28. stringToArrayBuffer
  29. } from '@converse/headless/utils/arraybuffer.js';
  30. import MUC from '@converse/headless/plugins/muc/muc.js';
  31. import {IQError, UserFacingError} from 'shared/errors.js';
  32. import DeviceLists from './devicelists.js';
  33. const { Strophe, URI, sizzle, u } = converse.env;
  34. export function formatFingerprint (fp) {
  35. fp = fp.replace(/^05/, '');
  36. for (let i=1; i<8; i++) {
  37. const idx = i*8+i-1;
  38. fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
  39. }
  40. return fp;
  41. }
  42. /**
  43. * @param {Error|IQError|UserFacingError} e
  44. * @param {ChatBox} chat
  45. */
  46. export function handleMessageSendError (e, chat) {
  47. if (e instanceof IQError) {
  48. chat.save('omemo_supported', false);
  49. const err_msgs = [];
  50. if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
  51. err_msgs.push(
  52. __(
  53. "Sorry, we're unable to send an encrypted message because %1$s " +
  54. 'requires you to be subscribed to their presence in order to see their OMEMO information',
  55. e.iq.getAttribute('from')
  56. )
  57. );
  58. } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
  59. err_msgs.push(
  60. __(
  61. "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
  62. e.iq.getAttribute('from')
  63. )
  64. );
  65. } else {
  66. err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
  67. err_msgs.push(e.iq.outerHTML);
  68. }
  69. api.alert('error', __('Error'), err_msgs);
  70. } else if (e instanceof UserFacingError) {
  71. api.alert('error', __('Error'), [e.message]);
  72. }
  73. throw e;
  74. }
  75. export async function contactHasOMEMOSupport (jid) {
  76. /* Checks whether the contact advertises any OMEMO-compatible devices. */
  77. const devices = await getDevicesForContact(jid);
  78. return devices.length > 0;
  79. }
  80. export function getOutgoingMessageAttributes (chat, attrs) {
  81. if (chat.get('omemo_active') && attrs.body) {
  82. attrs['is_encrypted'] = true;
  83. attrs['plaintext'] = attrs.body;
  84. attrs['body'] = __(
  85. 'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
  86. 'Find more information on https://conversations.im/omemo'
  87. );
  88. }
  89. return attrs;
  90. }
  91. /**
  92. * @param {string} plaintext
  93. */
  94. async function encryptMessage (plaintext) {
  95. // The client MUST use fresh, randomly generated key/IV pairs
  96. // with AES-128 in Galois/Counter Mode (GCM).
  97. // For GCM a 12 byte IV is strongly suggested as other IV lengths
  98. // will require additional calculations. In principle any IV size
  99. // can be used as long as the IV doesn't ever repeat. NIST however
  100. // suggests that only an IV size of 12 bytes needs to be supported
  101. // by implementations.
  102. //
  103. // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
  104. const iv = crypto.getRandomValues(new window.Uint8Array(12));
  105. const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']);
  106. const algo = {
  107. 'name': 'AES-GCM',
  108. 'iv': iv,
  109. 'tagLength': TAG_LENGTH
  110. };
  111. const encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext));
  112. const length = encrypted.byteLength - ((128 + 7) >> 3);
  113. const ciphertext = encrypted.slice(0, length);
  114. const tag = encrypted.slice(length);
  115. const exported_key = await crypto.subtle.exportKey('raw', key);
  116. return {
  117. 'key': exported_key,
  118. 'tag': tag,
  119. 'key_and_tag': appendArrayBuffer(exported_key, tag),
  120. 'payload': arrayBufferToBase64(ciphertext),
  121. 'iv': arrayBufferToBase64(iv)
  122. };
  123. }
  124. async function decryptMessage (obj) {
  125. const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']);
  126. const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag);
  127. const algo = {
  128. 'name': 'AES-GCM',
  129. 'iv': base64ToArrayBuffer(obj.iv),
  130. 'tagLength': TAG_LENGTH
  131. };
  132. return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
  133. }
  134. /**
  135. * @param {File} file
  136. * @returns {Promise<File>}
  137. */
  138. export async function encryptFile (file) {
  139. const iv = crypto.getRandomValues(new Uint8Array(12));
  140. const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt']);
  141. const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, }, key, await file.arrayBuffer());
  142. const exported_key = await window.crypto.subtle.exportKey('raw', key);
  143. const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified });
  144. Object.assign(encrypted_file, { xep454_ivkey: arrayBufferToHex(iv) + arrayBufferToHex(exported_key) });
  145. return encrypted_file;
  146. }
  147. export function setEncryptedFileURL (message, attrs) {
  148. const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey;
  149. return Object.assign(attrs, {
  150. 'oob_url': null, // Since only the body gets encrypted, we don't set the oob_url
  151. 'message': url,
  152. 'body': url
  153. });
  154. }
  155. async function decryptFile (iv, key, cipher) {
  156. const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
  157. const algo = {
  158. 'name': 'AES-GCM',
  159. 'iv': hexToArrayBuffer(iv),
  160. };
  161. return crypto.subtle.decrypt(algo, key_obj, cipher);
  162. }
  163. async function downloadFile(url) {
  164. let response;
  165. try {
  166. response = await fetch(url)
  167. } catch(e) {
  168. log.error(`${e.name}: Failed to download encrypted media: ${url}`);
  169. log.error(e);
  170. return null;
  171. }
  172. if (response.status >= 200 && response.status < 400) {
  173. return response.arrayBuffer();
  174. }
  175. }
  176. async function getAndDecryptFile (uri) {
  177. const protocol = (window.location.hostname === 'localhost' && uri.domain() === 'localhost') ? 'http' : 'https';
  178. const http_url = uri.toString().replace(/^aesgcm/, protocol);
  179. const cipher = await downloadFile(http_url);
  180. if (cipher === null) {
  181. log.error(`Could not decrypt a received encrypted file ${uri.toString()} since it could not be downloaded`);
  182. return new Error(__('Error: could not decrypt a received encrypted file, because it could not be downloaded'));
  183. }
  184. const hash = uri.hash().slice(1);
  185. const key = hash.substring(hash.length-64);
  186. const iv = hash.replace(key, '');
  187. let content;
  188. try {
  189. content = await decryptFile(iv, key, cipher);
  190. } catch (e) {
  191. log.error(`Could not decrypt file ${uri.toString()}`);
  192. log.error(e);
  193. return null;
  194. }
  195. const [filename, extension] = uri.filename().split('.');
  196. const mimetype = MIMETYPES_MAP[extension];
  197. try {
  198. const file = new File([content], filename, { 'type': mimetype });
  199. return URL.createObjectURL(file);
  200. } catch (e) {
  201. log.error(`Could not decrypt file ${uri.toString()}`);
  202. log.error(e);
  203. return null;
  204. }
  205. }
  206. function getTemplateForObjectURL (uri, obj_url, richtext) {
  207. if (isError(obj_url)) {
  208. return html`<p class="error">${obj_url.message}</p>`;
  209. }
  210. const file_url = uri.toString();
  211. if (isImageURL(file_url)) {
  212. return tplImage({
  213. 'src': obj_url,
  214. 'onClick': richtext.onImgClick,
  215. 'onLoad': richtext.onImgLoad
  216. });
  217. } else if (isAudioURL(file_url)) {
  218. return tplAudio(obj_url);
  219. } else if (isVideoURL(file_url)) {
  220. return tplVideo(obj_url);
  221. } else {
  222. return tplFile(obj_url, uri.filename());
  223. }
  224. }
  225. function addEncryptedFiles(text, offset, richtext) {
  226. const objs = [];
  227. try {
  228. const parse_options = { 'start': /\b(aesgcm:\/\/)/gi };
  229. URI.withinString(
  230. text,
  231. (url, start, end) => {
  232. objs.push({ url, start, end });
  233. return url;
  234. },
  235. parse_options
  236. );
  237. } catch (error) {
  238. log.debug(error);
  239. return;
  240. }
  241. objs.forEach(o => {
  242. const uri = getURI(text.slice(o.start, o.end));
  243. const promise = getAndDecryptFile(uri)
  244. .then(obj_url => getTemplateForObjectURL(uri, obj_url, richtext));
  245. const template = html`${until(promise, '')}`;
  246. richtext.addTemplateResult(o.start + offset, o.end + offset, template);
  247. });
  248. }
  249. export function handleEncryptedFiles (richtext) {
  250. if (!_converse.state.config.get('trusted')) {
  251. return;
  252. }
  253. richtext.addAnnotations((text, offset) => addEncryptedFiles(text, offset, richtext));
  254. }
  255. /**
  256. * Hook handler for { @link parseMessage } and { @link parseMUCMessage }, which
  257. * parses the passed in `message` stanza for OMEMO attributes and then sets
  258. * them on the attrs object.
  259. * @param { Element } stanza - The message stanza
  260. * @param { (MUCMessageAttributes|MessageAttributes) } attrs
  261. * @returns (MUCMessageAttributes|MessageAttributes)
  262. */
  263. export async function parseEncryptedMessage (stanza, attrs) {
  264. if (api.settings.get('clear_cache_on_logout') ||
  265. !attrs.is_encrypted ||
  266. attrs.encryption_namespace !== Strophe.NS.OMEMO) {
  267. return attrs;
  268. }
  269. const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
  270. const header = encrypted_el.querySelector('header');
  271. attrs.encrypted = { 'device_id': header.getAttribute('sid') };
  272. const device_id = await api.omemo?.getDeviceID();
  273. const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop();
  274. if (key) {
  275. Object.assign(attrs.encrypted, {
  276. 'iv': header.querySelector('iv').textContent,
  277. 'key': key.textContent,
  278. 'payload': encrypted_el.querySelector('payload')?.textContent || null,
  279. 'prekey': ['true', '1'].includes(key.getAttribute('prekey'))
  280. });
  281. } else {
  282. return Object.assign(attrs, {
  283. 'error_condition': 'not-encrypted-for-this-device',
  284. 'error_type': 'Decryption',
  285. 'is_ephemeral': true,
  286. 'is_error': true,
  287. 'type': 'error'
  288. });
  289. }
  290. // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
  291. if (attrs.encrypted.prekey === true) {
  292. return decryptPrekeyWhisperMessage(attrs);
  293. } else {
  294. return decryptWhisperMessage(attrs);
  295. }
  296. }
  297. export function onChatBoxesInitialized () {
  298. _converse.state.chatboxes.on('add', chatbox => {
  299. checkOMEMOSupported(chatbox);
  300. if (chatbox.get('type') === CHATROOMS_TYPE) {
  301. chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o));
  302. chatbox.features.on('change', () => checkOMEMOSupported(chatbox));
  303. }
  304. });
  305. }
  306. export function onChatInitialized (el) {
  307. el.listenTo(el.model.messages, 'add', message => {
  308. if (message.get('is_encrypted') && !message.get('is_error')) {
  309. el.model.save('omemo_supported', true);
  310. }
  311. });
  312. el.listenTo(el.model, 'change:omemo_supported', () => {
  313. if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) {
  314. el.model.set('omemo_active', false);
  315. } else {
  316. // Manually trigger an update, setting omemo_active to
  317. // false above will automatically trigger one.
  318. el.querySelector('converse-chat-toolbar')?.requestUpdate();
  319. }
  320. });
  321. el.listenTo(el.model, 'change:omemo_active', () => {
  322. el.querySelector('converse-chat-toolbar').requestUpdate();
  323. });
  324. }
  325. export function getSessionCipher (jid, id) {
  326. const { libsignal } = /** @type WindowWithLibsignal */(window);
  327. const address = new libsignal.SignalProtocolAddress(jid, id);
  328. return new libsignal.SessionCipher(_converse.state.omemo_store, address);
  329. }
  330. function getJIDForDecryption (attrs) {
  331. const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from;
  332. if (!from_jid) {
  333. Object.assign(attrs, {
  334. 'error_text': __("Sorry, could not decrypt a received OMEMO "+
  335. "message because we don't have the XMPP address for that user."),
  336. 'error_type': 'Decryption',
  337. 'is_ephemeral': true,
  338. 'is_error': true,
  339. 'type': 'error'
  340. });
  341. throw new Error("Could not find JID to decrypt OMEMO message for");
  342. }
  343. return from_jid;
  344. }
  345. async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
  346. const from_jid = getJIDForDecryption(attrs);
  347. const devicelist = await api.omemo.devicelists.get(from_jid, true);
  348. const encrypted = attrs.encrypted;
  349. let device = devicelist.devices.get(encrypted.device_id);
  350. if (!device) {
  351. device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true });
  352. }
  353. if (encrypted.payload) {
  354. const key = key_and_tag.slice(0, 16);
  355. const tag = key_and_tag.slice(16);
  356. const result = await omemo.decryptMessage(Object.assign(encrypted, { 'key': key, 'tag': tag }));
  357. device.save('active', true);
  358. return result;
  359. }
  360. }
  361. function getDecryptionErrorAttributes (e) {
  362. return {
  363. 'error_text':
  364. __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
  365. 'error_condition': e.name,
  366. 'error_message': e.message,
  367. 'error_type': 'Decryption',
  368. 'is_ephemeral': true,
  369. 'is_error': true,
  370. 'type': 'error'
  371. };
  372. }
  373. async function decryptPrekeyWhisperMessage (attrs) {
  374. const from_jid = getJIDForDecryption(attrs);
  375. const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
  376. const key = base64ToArrayBuffer(attrs.encrypted.key);
  377. let key_and_tag;
  378. try {
  379. key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
  380. } catch (e) {
  381. // TODO from the XEP:
  382. // There are various reasons why decryption of an
  383. // OMEMOKeyExchange or an OMEMOAuthenticatedMessage
  384. // could fail. One reason is if the message was
  385. // received twice and already decrypted once, in this
  386. // case the client MUST ignore the decryption failure
  387. // and not show any warnings/errors. In all other cases
  388. // of decryption failure, clients SHOULD respond by
  389. // forcibly doing a new key exchange and sending a new
  390. // OMEMOKeyExchange with a potentially empty SCE
  391. // payload. By building a new session with the original
  392. // sender this way, the invalid session of the original
  393. // sender will get overwritten with this newly created,
  394. // valid session.
  395. log.error(`${e.name} ${e.message}`);
  396. return Object.assign(attrs, getDecryptionErrorAttributes(e));
  397. }
  398. // TODO from the XEP:
  399. // When a client receives the first message for a given
  400. // ratchet key with a counter of 53 or higher, it MUST send
  401. // a heartbeat message. Heartbeat messages are normal OMEMO
  402. // encrypted messages where the SCE payload does not include
  403. // any elements. These heartbeat messages cause the ratchet
  404. // to forward, thus consequent messages will have the
  405. // counter restarted from 0.
  406. try {
  407. const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
  408. const { omemo_store } = _converse.state;
  409. await omemo_store.generateMissingPreKeys();
  410. await omemo_store.publishBundle();
  411. if (plaintext) {
  412. return Object.assign(attrs, { 'plaintext': plaintext });
  413. } else {
  414. return Object.assign(attrs, { 'is_only_key': true });
  415. }
  416. } catch (e) {
  417. log.error(`${e.name} ${e.message}`);
  418. return Object.assign(attrs, getDecryptionErrorAttributes(e));
  419. }
  420. }
  421. async function decryptWhisperMessage (attrs) {
  422. const from_jid = getJIDForDecryption(attrs);
  423. const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
  424. const key = base64ToArrayBuffer(attrs.encrypted.key);
  425. try {
  426. const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary');
  427. const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
  428. return Object.assign(attrs, { 'plaintext': plaintext });
  429. } catch (e) {
  430. log.error(`${e.name} ${e.message}`);
  431. return Object.assign(attrs, getDecryptionErrorAttributes(e));
  432. }
  433. }
  434. export function addKeysToMessageStanza (stanza, dicts, iv) {
  435. for (const i in dicts) {
  436. if (Object.prototype.hasOwnProperty.call(dicts, i)) {
  437. const payload = dicts[i].payload;
  438. const device = dicts[i].device;
  439. const prekey = 3 == parseInt(payload.type, 10);
  440. stanza.c('key', { 'rid': device.get('id') }).t(btoa(payload.body));
  441. if (prekey) {
  442. stanza.attrs({ 'prekey': prekey });
  443. }
  444. stanza.up();
  445. }
  446. }
  447. stanza.c('iv').t(iv).up().up();
  448. return Promise.resolve(stanza);
  449. }
  450. /**
  451. * Given an XML element representing a user's OMEMO bundle, parse it
  452. * and return a map.
  453. */
  454. export function parseBundle (bundle_el) {
  455. const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
  456. const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
  457. const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({
  458. 'id': parseInt(el.getAttribute('preKeyId'), 10),
  459. 'key': el.textContent
  460. }));
  461. return {
  462. 'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
  463. 'signed_prekey': {
  464. 'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
  465. 'public_key': signed_prekey_public_el.textContent,
  466. 'signature': signed_prekey_signature_el.textContent
  467. },
  468. 'prekeys': prekeys
  469. };
  470. }
  471. export async function generateFingerprints (jid) {
  472. const devices = await getDevicesForContact(jid);
  473. return Promise.all(devices.map(d => generateFingerprint(d)));
  474. }
  475. export async function generateFingerprint (device) {
  476. if (device.get('bundle')?.fingerprint) {
  477. return;
  478. }
  479. const bundle = await device.getBundle();
  480. bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key']));
  481. device.save('bundle', bundle);
  482. device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
  483. }
  484. export async function getDevicesForContact (jid) {
  485. await api.waitUntil('OMEMOInitialized');
  486. const devicelist = await api.omemo.devicelists.get(jid, true);
  487. await devicelist.fetchDevices();
  488. return devicelist.devices;
  489. }
  490. export function getDeviceForContact (jid, device_id) {
  491. return getDevicesForContact(jid).then(devices => devices.get(device_id));
  492. }
  493. export async function generateDeviceID () {
  494. const { libsignal } = /** @type WindowWithLibsignal */(window);
  495. /* Generates a device ID, making sure that it's unique */
  496. const bare_jid = _converse.session.get('bare_jid');
  497. const devicelist = await api.omemo.devicelists.get(bare_jid, true);
  498. const existing_ids = devicelist.devices.pluck('id');
  499. let device_id = libsignal.KeyHelper.generateRegistrationId();
  500. // Before publishing a freshly generated device id for the first time,
  501. // a device MUST check whether that device id already exists, and if so, generate a new one.
  502. let i = 0;
  503. while (existing_ids.includes(device_id)) {
  504. device_id = libsignal.KeyHelper.generateRegistrationId();
  505. i++;
  506. if (i === 10) {
  507. throw new Error('Unable to generate a unique device ID');
  508. }
  509. }
  510. return device_id.toString();
  511. }
  512. async function buildSession (device) {
  513. const { libsignal } = /** @type WindowWithLibsignal */(window);
  514. const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
  515. const sessionBuilder = new libsignal.SessionBuilder(_converse.state.omemo_store, address);
  516. const prekey = device.getRandomPreKey();
  517. const bundle = await device.getBundle();
  518. return sessionBuilder.processPreKey({
  519. 'registrationId': parseInt(device.get('id'), 10),
  520. 'identityKey': base64ToArrayBuffer(bundle.identity_key),
  521. 'signedPreKey': {
  522. 'keyId': bundle.signed_prekey.id, // <Number>
  523. 'publicKey': base64ToArrayBuffer(bundle.signed_prekey.public_key),
  524. 'signature': base64ToArrayBuffer(bundle.signed_prekey.signature)
  525. },
  526. 'preKey': {
  527. 'keyId': prekey.id, // <Number>
  528. 'publicKey': base64ToArrayBuffer(prekey.key)
  529. }
  530. });
  531. }
  532. export async function getSession (device) {
  533. if (!device.get('bundle')) {
  534. log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
  535. return null;
  536. }
  537. const { libsignal } = /** @type WindowWithLibsignal */(window);
  538. const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
  539. const session = await _converse.state.omemo_store.loadSession(address.toString());
  540. if (session) {
  541. return session;
  542. } else {
  543. try {
  544. const session = await buildSession(device);
  545. return session;
  546. } catch (e) {
  547. log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
  548. log.error(e);
  549. return null;
  550. }
  551. }
  552. }
  553. async function updateBundleFromStanza (stanza) {
  554. const items_el = sizzle(`items`, stanza).pop();
  555. if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
  556. return;
  557. }
  558. const device_id = items_el.getAttribute('node').split(':')[1];
  559. const jid = stanza.getAttribute('from');
  560. const bundle_el = sizzle(`item > bundle`, items_el).pop();
  561. const devicelist = await api.omemo.devicelists.get(jid, true);
  562. const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid });
  563. device.save({ 'bundle': parseBundle(bundle_el) });
  564. }
  565. async function updateDevicesFromStanza (stanza) {
  566. const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
  567. if (!items_el) {
  568. return;
  569. }
  570. const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
  571. const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
  572. const jid = stanza.getAttribute('from');
  573. const devicelist = await api.omemo.devicelists.get(jid, true);
  574. const devices = devicelist.devices;
  575. const removed_ids = devices.pluck('id').filter(id => !device_ids.includes(id));
  576. const bare_jid = _converse.session.get('bare_jid');
  577. removed_ids.forEach(id => {
  578. if (jid === bare_jid && id === _converse.state.omemo_store.get('device_id')) {
  579. return; // We don't set the current device as inactive
  580. }
  581. devices.get(id).save('active', false);
  582. });
  583. device_ids.forEach(device_id => {
  584. const device = devices.get(device_id);
  585. if (device) {
  586. device.save('active', true);
  587. } else {
  588. devices.create({ 'id': device_id, 'jid': jid });
  589. }
  590. });
  591. if (u.isSameBareJID(jid, jid)) {
  592. // Make sure our own device is on the list
  593. // (i.e. if it was removed, add it again).
  594. devicelist.publishCurrentDevice(device_ids);
  595. }
  596. }
  597. export function registerPEPPushHandler () {
  598. // Add a handler for devices pushed from other connected clients
  599. api.connection.get().addHandler(
  600. async (message) => {
  601. try {
  602. if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
  603. await api.waitUntil('OMEMOInitialized');
  604. await updateDevicesFromStanza(message);
  605. await updateBundleFromStanza(message);
  606. }
  607. } catch (e) {
  608. log.error(e.message);
  609. }
  610. return true;
  611. },
  612. null,
  613. 'message',
  614. 'headline'
  615. );
  616. }
  617. export async function restoreOMEMOSession () {
  618. }
  619. async function fetchDeviceLists () {
  620. const bare_jid = _converse.session.get('bare_jid');
  621. _converse.state.devicelists = new DeviceLists();
  622. const id = `converse.devicelists-${bare_jid}`;
  623. initStorage(_converse.state.devicelists, id);
  624. await new Promise(resolve => {
  625. _converse.state.devicelists.fetch({
  626. 'success': resolve,
  627. 'error': (_m, e) => { log.error(e); resolve(); }
  628. })
  629. });
  630. // Call API method to wait for our own device list to be fetched from the
  631. // server or to be created. If we have no pre-existing OMEMO session, this
  632. // will cause a new device and bundle to be generated and published.
  633. await api.omemo.devicelists.get(bare_jid, true);
  634. }
  635. export async function initOMEMO (reconnecting) {
  636. if (reconnecting) {
  637. return;
  638. }
  639. if (!_converse.state.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
  640. log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true');
  641. return;
  642. }
  643. try {
  644. await fetchDeviceLists();
  645. await api.omemo.session.restore();
  646. await _converse.state.omemo_store.publishBundle();
  647. } catch (e) {
  648. log.error('Could not initialize OMEMO support');
  649. log.error(e);
  650. return;
  651. }
  652. /**
  653. * Triggered once OMEMO support has been initialized
  654. * @event _converse#OMEMOInitialized
  655. * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
  656. */
  657. api.trigger('OMEMOInitialized');
  658. }
  659. async function onOccupantAdded (chatroom, occupant) {
  660. if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
  661. return;
  662. }
  663. if (chatroom.get('omemo_active')) {
  664. const supported = await contactHasOMEMOSupport(occupant.get('jid'));
  665. if (!supported) {
  666. chatroom.createMessage({
  667. 'message': __(
  668. "%1$s doesn't appear to have a client that supports OMEMO. " +
  669. 'Encrypted chat will no longer be possible in this grouchat.',
  670. occupant.get('nick')
  671. ),
  672. 'type': 'error'
  673. });
  674. chatroom.save({ 'omemo_active': false, 'omemo_supported': false });
  675. }
  676. }
  677. }
  678. async function checkOMEMOSupported (chatbox) {
  679. let supported;
  680. if (chatbox.get('type') === CHATROOMS_TYPE) {
  681. await api.waitUntil('OMEMOInitialized');
  682. supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
  683. } else if (chatbox.get('type') === PRIVATE_CHAT_TYPE) {
  684. supported = await contactHasOMEMOSupport(chatbox.get('jid'));
  685. }
  686. chatbox.set('omemo_supported', supported);
  687. if (supported && api.settings.get('omemo_default')) {
  688. chatbox.set('omemo_active', true);
  689. }
  690. }
  691. function toggleOMEMO (ev) {
  692. ev.stopPropagation();
  693. ev.preventDefault();
  694. const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
  695. if (!toolbar_el.model.get('omemo_supported')) {
  696. let messages;
  697. if (toolbar_el.model.get('type') === CHATROOMS_TYPE) {
  698. messages = [
  699. __(
  700. 'Cannot use end-to-end encryption in this groupchat, ' +
  701. 'either the groupchat has some anonymity or not all participants support OMEMO.'
  702. )
  703. ];
  704. } else {
  705. messages = [
  706. __(
  707. "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
  708. toolbar_el.model.contact.getDisplayName()
  709. )
  710. ];
  711. }
  712. return api.alert('error', __('Error'), messages);
  713. }
  714. toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') });
  715. }
  716. export function getOMEMOToolbarButton (toolbar_el, buttons) {
  717. const model = toolbar_el.model;
  718. const is_muc = model.get('type') === CHATROOMS_TYPE;
  719. let title;
  720. if (model.get('omemo_supported')) {
  721. const i18n_plaintext = __('Messages are being sent in plaintext');
  722. const i18n_encrypted = __('Messages are sent encrypted');
  723. title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
  724. } else if (is_muc) {
  725. title = __(
  726. 'This groupchat needs to be members-only and non-anonymous in ' +
  727. 'order to support OMEMO encrypted messages'
  728. );
  729. } else {
  730. title = __('OMEMO encryption is not supported');
  731. }
  732. let color;
  733. if (model.get('omemo_supported')) {
  734. if (model.get('omemo_active')) {
  735. color = is_muc ? `var(--muc-color)` : `var(--chat-toolbar-btn-color)`;
  736. } else {
  737. color = `var(--error-color)`;
  738. }
  739. } else {
  740. color = `var(--muc-toolbar-btn-disabled-color)`;
  741. }
  742. buttons.push(html`
  743. <button class="toggle-omemo" title="${title}" data-disabled=${!model.get('omemo_supported')} @click=${toggleOMEMO}>
  744. <converse-icon
  745. class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
  746. path-prefix="${api.settings.get('assets_path')}"
  747. size="1em"
  748. color="${color}"
  749. ></converse-icon>
  750. </button>
  751. `);
  752. return buttons;
  753. }
  754. /**
  755. * @param {MUC|ChatBox} chatbox
  756. */
  757. async function getBundlesAndBuildSessions (chatbox) {
  758. const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
  759. let devices;
  760. if (chatbox instanceof MUC) {
  761. const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
  762. devices = collections.reduce((a, b) => a.concat(b.models), []);
  763. } else if (chatbox.get('type') === PRIVATE_CHAT_TYPE) {
  764. const their_devices = await getDevicesForContact(chatbox.get('jid'));
  765. if (their_devices.length === 0) {
  766. throw new UserFacingError(no_devices_err);
  767. }
  768. const bare_jid = _converse.session.get('bare_jid');
  769. const own_list = await api.omemo.devicelists.get(bare_jid);
  770. const own_devices = own_list.devices;
  771. devices = [...own_devices.models, ...their_devices.models];
  772. }
  773. // Filter out our own device
  774. const id = _converse.state.omemo_store.get('device_id');
  775. devices = devices.filter(d => d.get('id') !== id);
  776. // Fetch bundles if necessary
  777. await Promise.all(devices.map(d => d.getBundle()));
  778. const sessions = devices.filter(d => d).map(d => getSession(d));
  779. await Promise.all(sessions);
  780. if (sessions.includes(null)) {
  781. // We couldn't build a session for certain devices.
  782. devices = devices.filter(d => sessions[devices.indexOf(d)]);
  783. if (devices.length === 0) {
  784. throw new UserFacingError(no_devices_err);
  785. }
  786. }
  787. return devices;
  788. }
  789. function encryptKey (key_and_tag, device) {
  790. return getSessionCipher(device.get('jid'), device.get('id'))
  791. .encrypt(key_and_tag)
  792. .then(payload => ({ 'payload': payload, 'device': device }));
  793. }
  794. export async function createOMEMOMessageStanza (chat, data) {
  795. let { stanza } = data;
  796. const { message } = data;
  797. if (!message.get('is_encrypted')) {
  798. return data;
  799. }
  800. if (!message.get('body')) {
  801. throw new Error('No message body to encrypt!');
  802. }
  803. const devices = await getBundlesAndBuildSessions(chat);
  804. // An encrypted header is added to the message for
  805. // each device that is supposed to receive it.
  806. // These headers simply contain the key that the
  807. // payload message is encrypted with,
  808. // and they are separately encrypted using the
  809. // session corresponding to the counterpart device.
  810. stanza.c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
  811. .c('header', { 'sid': _converse.state.omemo_store.get('device_id') });
  812. const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext'));
  813. // The 16 bytes key and the GCM authentication tag (The tag
  814. // SHOULD have at least 128 bit) are concatenated and for each
  815. // intended recipient device, i.e. both own devices as well as
  816. // devices associated with the contact, the result of this
  817. // concatenation is encrypted using the corresponding
  818. // long-standing SignalProtocol session.
  819. const dicts = await Promise.all(devices
  820. .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
  821. .map(device => encryptKey(key_and_tag, device)));
  822. stanza = await addKeysToMessageStanza(stanza, dicts, iv);
  823. stanza.c('payload').t(payload).up().up();
  824. stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up();
  825. stanza.c('encryption', { 'xmlns': Strophe.NS.EME, namespace: Strophe.NS.OMEMO });
  826. return { message, stanza };
  827. }
  828. export const omemo = {
  829. decryptMessage,
  830. encryptMessage,
  831. formatFingerprint
  832. }