utils.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661
  1. /* global libsignal */
  2. import URI from 'urijs';
  3. import difference from 'lodash-es/difference';
  4. import log from '@converse/headless/log';
  5. import tpl_audio from 'templates/audio.js';
  6. import tpl_file from 'templates/file.js';
  7. import tpl_image from 'templates/image.js';
  8. import tpl_video from 'templates/video.js';
  9. import { __ } from 'i18n';
  10. import { _converse, converse, api } from '@converse/headless/core';
  11. import { html } from 'lit';
  12. import { initStorage } from '@converse/headless/shared/utils.js';
  13. import { isAudioURL, isImageURL, isVideoURL, getURI } from 'utils/html.js';
  14. import { until } from 'lit/directives/until.js';
  15. import { MIMETYPES_MAP } from 'utils/file.js';
  16. import {
  17. appendArrayBuffer,
  18. arrayBufferToBase64,
  19. arrayBufferToHex,
  20. arrayBufferToString,
  21. base64ToArrayBuffer,
  22. hexToArrayBuffer,
  23. stringToArrayBuffer
  24. } from '@converse/headless/utils/arraybuffer.js';
  25. const { Strophe, sizzle, u } = converse.env;
  26. const TAG_LENGTH = 128;
  27. const KEY_ALGO = {
  28. 'name': 'AES-GCM',
  29. 'length': 128
  30. };
  31. export const omemo = {
  32. async encryptMessage (plaintext) {
  33. // The client MUST use fresh, randomly generated key/IV pairs
  34. // with AES-128 in Galois/Counter Mode (GCM).
  35. // For GCM a 12 byte IV is strongly suggested as other IV lengths
  36. // will require additional calculations. In principle any IV size
  37. // can be used as long as the IV doesn't ever repeat. NIST however
  38. // suggests that only an IV size of 12 bytes needs to be supported
  39. // by implementations.
  40. //
  41. // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
  42. const iv = crypto.getRandomValues(new window.Uint8Array(12)),
  43. key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']),
  44. algo = {
  45. 'name': 'AES-GCM',
  46. 'iv': iv,
  47. 'tagLength': TAG_LENGTH
  48. },
  49. encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext)),
  50. length = encrypted.byteLength - ((128 + 7) >> 3),
  51. ciphertext = encrypted.slice(0, length),
  52. tag = encrypted.slice(length),
  53. exported_key = await crypto.subtle.exportKey('raw', key);
  54. return {
  55. 'key': exported_key,
  56. 'tag': tag,
  57. 'key_and_tag': appendArrayBuffer(exported_key, tag),
  58. 'payload': arrayBufferToBase64(ciphertext),
  59. 'iv': arrayBufferToBase64(iv)
  60. };
  61. },
  62. async decryptMessage (obj) {
  63. const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']);
  64. const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag);
  65. const algo = {
  66. 'name': 'AES-GCM',
  67. 'iv': base64ToArrayBuffer(obj.iv),
  68. 'tagLength': TAG_LENGTH
  69. };
  70. return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
  71. }
  72. }
  73. async function decryptFile (iv, key, cipher) {
  74. const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
  75. const algo = {
  76. 'name': 'AES-GCM',
  77. 'iv': hexToArrayBuffer(iv),
  78. };
  79. return crypto.subtle.decrypt(algo, key_obj, cipher);
  80. }
  81. async function downloadFile(url) {
  82. let response;
  83. try {
  84. response = await fetch(url)
  85. } catch(e) {
  86. log.error(`Failed to download encrypted media: ${url}`);
  87. log.error(e);
  88. return null;
  89. }
  90. if (response.status >= 200 && response.status < 400) {
  91. return response.arrayBuffer();
  92. }
  93. }
  94. async function getAndDecryptFile (uri) {
  95. const hash = uri.hash().slice(1);
  96. const protocol = window.location.hostname === 'localhost' ? 'http' : 'https';
  97. const http_url = uri.toString().replace(/^aesgcm/, protocol);
  98. const cipher = await downloadFile(http_url);
  99. const iv = hash.slice(0, 24);
  100. const key = hash.slice(24);
  101. let content;
  102. try {
  103. content = await decryptFile(iv, key, cipher);
  104. } catch (e) {
  105. log.error(`Could not decrypt file ${uri.toString()}`);
  106. log.error(e);
  107. return null;
  108. }
  109. const [filename, extension] = uri.filename()?.split('.');
  110. const mimetype = MIMETYPES_MAP[extension];
  111. try {
  112. const file = new File([content], filename, { 'type': mimetype });
  113. return URL.createObjectURL(file);
  114. } catch (e) {
  115. log.error(`Could not decrypt file ${uri.toString()}`);
  116. log.error(e);
  117. return null;
  118. }
  119. }
  120. function getTemplateForObjectURL (uri, obj_url, richtext) {
  121. const file_url = uri.toString();
  122. if (obj_url === null) {
  123. return file_url;
  124. }
  125. if (isImageURL(file_url)) {
  126. return tpl_image({
  127. 'url': obj_url,
  128. 'onClick': richtext.onImgClick,
  129. 'onLoad': richtext.onImgLoad
  130. });
  131. } else if (isAudioURL(file_url)) {
  132. return tpl_audio(obj_url);
  133. } else if (isVideoURL(file_url)) {
  134. return tpl_video(obj_url);
  135. } else {
  136. return tpl_file(obj_url, uri.filename());
  137. }
  138. }
  139. function addEncryptedFiles(text, offset, richtext) {
  140. const objs = [];
  141. try {
  142. const parse_options = { 'start': /\b(aesgcm:\/\/)/gi };
  143. URI.withinString(
  144. text,
  145. (url, start, end) => {
  146. objs.push({ url, start, end });
  147. return url;
  148. },
  149. parse_options
  150. );
  151. } catch (error) {
  152. log.debug(error);
  153. return;
  154. }
  155. objs.forEach(o => {
  156. const uri = getURI(text.slice(o.start, o.end));
  157. const promise = getAndDecryptFile(uri)
  158. .then(obj_url => getTemplateForObjectURL(uri, obj_url, richtext));
  159. const template = html`${until(promise, '')}`;
  160. richtext.addTemplateResult(o.start + offset, o.end + offset, template);
  161. });
  162. }
  163. export function handleEncryptedFiles (richtext) {
  164. if (!_converse.config.get('trusted')) {
  165. return;
  166. }
  167. richtext.addAnnotations((text, offset) => addEncryptedFiles(text, offset, richtext));
  168. }
  169. export function parseEncryptedMessage (stanza, attrs) {
  170. if (attrs.is_encrypted && attrs.encrypted.key) {
  171. // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
  172. if (attrs.encrypted.prekey === true) {
  173. return decryptPrekeyWhisperMessage(attrs);
  174. } else {
  175. return decryptWhisperMessage(attrs);
  176. }
  177. } else {
  178. return attrs;
  179. }
  180. }
  181. export function onChatBoxesInitialized () {
  182. _converse.chatboxes.on('add', chatbox => {
  183. checkOMEMOSupported(chatbox);
  184. if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
  185. chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o));
  186. chatbox.features.on('change', () => checkOMEMOSupported(chatbox));
  187. }
  188. });
  189. }
  190. export function onChatInitialized (el) {
  191. el.listenTo(el.model.messages, 'add', message => {
  192. if (message.get('is_encrypted') && !message.get('is_error')) {
  193. el.model.save('omemo_supported', true);
  194. }
  195. });
  196. el.listenTo(el.model, 'change:omemo_supported', () => {
  197. if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) {
  198. el.model.set('omemo_active', false);
  199. } else {
  200. // Manually trigger an update, setting omemo_active to
  201. // false above will automatically trigger one.
  202. el.querySelector('converse-chat-toolbar')?.requestUpdate();
  203. }
  204. });
  205. el.listenTo(el.model, 'change:omemo_active', () => {
  206. el.querySelector('converse-chat-toolbar').requestUpdate();
  207. });
  208. }
  209. export function getSessionCipher (jid, id) {
  210. const address = new libsignal.SignalProtocolAddress(jid, id);
  211. return new window.libsignal.SessionCipher(_converse.omemo_store, address);
  212. }
  213. async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
  214. const encrypted = attrs.encrypted;
  215. const devicelist = _converse.devicelists.getDeviceList(attrs.from);
  216. await devicelist._devices_promise;
  217. let device = devicelist.get(encrypted.device_id);
  218. if (!device) {
  219. device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': attrs.from }, { 'promise': true });
  220. }
  221. if (encrypted.payload) {
  222. const key = key_and_tag.slice(0, 16);
  223. const tag = key_and_tag.slice(16);
  224. const result = await omemo.decryptMessage(Object.assign(encrypted, { 'key': key, 'tag': tag }));
  225. device.save('active', true);
  226. return result;
  227. }
  228. }
  229. function getDecryptionErrorAttributes (e) {
  230. if (api.settings.get('loglevel') === 'debug') {
  231. return {
  232. 'error_text':
  233. __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
  234. 'error_type': 'Decryption',
  235. 'is_ephemeral': true,
  236. 'is_error': true,
  237. 'type': 'error'
  238. };
  239. } else {
  240. return {};
  241. }
  242. }
  243. async function decryptPrekeyWhisperMessage (attrs) {
  244. const session_cipher = getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
  245. const key = base64ToArrayBuffer(attrs.encrypted.key);
  246. let key_and_tag;
  247. try {
  248. key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
  249. } catch (e) {
  250. // TODO from the XEP:
  251. // There are various reasons why decryption of an
  252. // OMEMOKeyExchange or an OMEMOAuthenticatedMessage
  253. // could fail. One reason is if the message was
  254. // received twice and already decrypted once, in this
  255. // case the client MUST ignore the decryption failure
  256. // and not show any warnings/errors. In all other cases
  257. // of decryption failure, clients SHOULD respond by
  258. // forcibly doing a new key exchange and sending a new
  259. // OMEMOKeyExchange with a potentially empty SCE
  260. // payload. By building a new session with the original
  261. // sender this way, the invalid session of the original
  262. // sender will get overwritten with this newly created,
  263. // valid session.
  264. log.error(`${e.name} ${e.message}`);
  265. return Object.assign(attrs, getDecryptionErrorAttributes(e));
  266. }
  267. // TODO from the XEP:
  268. // When a client receives the first message for a given
  269. // ratchet key with a counter of 53 or higher, it MUST send
  270. // a heartbeat message. Heartbeat messages are normal OMEMO
  271. // encrypted messages where the SCE payload does not include
  272. // any elements. These heartbeat messages cause the ratchet
  273. // to forward, thus consequent messages will have the
  274. // counter restarted from 0.
  275. try {
  276. const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
  277. await _converse.omemo_store.generateMissingPreKeys();
  278. await _converse.omemo_store.publishBundle();
  279. if (plaintext) {
  280. return Object.assign(attrs, { 'plaintext': plaintext });
  281. } else {
  282. return Object.assign(attrs, { 'is_only_key': true });
  283. }
  284. } catch (e) {
  285. log.error(`${e.name} ${e.message}`);
  286. return Object.assign(attrs, getDecryptionErrorAttributes(e));
  287. }
  288. }
  289. async function decryptWhisperMessage (attrs) {
  290. const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from;
  291. if (!from_jid) {
  292. Object.assign(attrs, {
  293. 'error_text': __("Sorry, could not decrypt a received OMEMO because we don't have the JID for that user."),
  294. 'error_type': 'Decryption',
  295. 'is_ephemeral': false,
  296. 'is_error': true,
  297. 'type': 'error'
  298. });
  299. }
  300. const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
  301. const key = base64ToArrayBuffer(attrs.encrypted.key);
  302. try {
  303. const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary');
  304. const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
  305. return Object.assign(attrs, { 'plaintext': plaintext });
  306. } catch (e) {
  307. log.error(`${e.name} ${e.message}`);
  308. return Object.assign(attrs, getDecryptionErrorAttributes(e));
  309. }
  310. }
  311. export function addKeysToMessageStanza (stanza, dicts, iv) {
  312. for (const i in dicts) {
  313. if (Object.prototype.hasOwnProperty.call(dicts, i)) {
  314. const payload = dicts[i].payload;
  315. const device = dicts[i].device;
  316. const prekey = 3 == parseInt(payload.type, 10);
  317. stanza.c('key', { 'rid': device.get('id') }).t(btoa(payload.body));
  318. if (prekey) {
  319. stanza.attrs({ 'prekey': prekey });
  320. }
  321. stanza.up();
  322. if (i == dicts.length - 1) {
  323. stanza
  324. .c('iv')
  325. .t(iv)
  326. .up()
  327. .up();
  328. }
  329. }
  330. }
  331. return Promise.resolve(stanza);
  332. }
  333. /**
  334. * Given an XML element representing a user's OMEMO bundle, parse it
  335. * and return a map.
  336. */
  337. export function parseBundle (bundle_el) {
  338. const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
  339. const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
  340. const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({
  341. 'id': parseInt(el.getAttribute('preKeyId'), 10),
  342. 'key': el.textContent
  343. }));
  344. return {
  345. 'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
  346. 'signed_prekey': {
  347. 'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
  348. 'public_key': signed_prekey_public_el.textContent,
  349. 'signature': signed_prekey_signature_el.textContent
  350. },
  351. 'prekeys': prekeys
  352. };
  353. }
  354. export async function generateFingerprint (device) {
  355. if (device.get('bundle')?.fingerprint) {
  356. return;
  357. }
  358. const bundle = await device.getBundle();
  359. bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key']));
  360. device.save('bundle', bundle);
  361. device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
  362. }
  363. export async function getDevicesForContact (jid) {
  364. await api.waitUntil('OMEMOInitialized');
  365. const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({ 'jid': jid });
  366. await devicelist.fetchDevices();
  367. return devicelist.devices;
  368. }
  369. export function generateDeviceID () {
  370. /* Generates a device ID, making sure that it's unique */
  371. const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
  372. let device_id = libsignal.KeyHelper.generateRegistrationId();
  373. // Before publishing a freshly generated device id for the first time,
  374. // a device MUST check whether that device id already exists, and if so, generate a new one.
  375. let i = 0;
  376. while (existing_ids.includes(device_id)) {
  377. device_id = libsignal.KeyHelper.generateRegistrationId();
  378. i++;
  379. if (i === 10) {
  380. throw new Error('Unable to generate a unique device ID');
  381. }
  382. }
  383. return device_id.toString();
  384. }
  385. async function buildSession (device) {
  386. // TODO: check device-get('jid') versus the 'from' attribute which is used
  387. // to build a session when receiving an encrypted message in a MUC.
  388. // https://github.com/conversejs/converse.js/issues/1481#issuecomment-509183431
  389. const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
  390. const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
  391. const prekey = device.getRandomPreKey();
  392. const bundle = await device.getBundle();
  393. return sessionBuilder.processPreKey({
  394. 'registrationId': parseInt(device.get('id'), 10),
  395. 'identityKey': base64ToArrayBuffer(bundle.identity_key),
  396. 'signedPreKey': {
  397. 'keyId': bundle.signed_prekey.id, // <Number>
  398. 'publicKey': base64ToArrayBuffer(bundle.signed_prekey.public_key),
  399. 'signature': base64ToArrayBuffer(bundle.signed_prekey.signature)
  400. },
  401. 'preKey': {
  402. 'keyId': prekey.id, // <Number>
  403. 'publicKey': base64ToArrayBuffer(prekey.key)
  404. }
  405. });
  406. }
  407. export async function getSession (device) {
  408. if (!device.get('bundle')) {
  409. log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
  410. return null;
  411. }
  412. const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
  413. const session = await _converse.omemo_store.loadSession(address.toString());
  414. if (session) {
  415. return session;
  416. } else {
  417. try {
  418. const session = await buildSession(device);
  419. return session;
  420. } catch (e) {
  421. log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
  422. log.error(e);
  423. return null;
  424. }
  425. }
  426. }
  427. function updateBundleFromStanza (stanza) {
  428. const items_el = sizzle(`items`, stanza).pop();
  429. if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
  430. return;
  431. }
  432. const device_id = items_el.getAttribute('node').split(':')[1];
  433. const jid = stanza.getAttribute('from');
  434. const bundle_el = sizzle(`item > bundle`, items_el).pop();
  435. const devicelist = _converse.devicelists.getDeviceList(jid);
  436. const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, 'jid': jid });
  437. device.save({ 'bundle': parseBundle(bundle_el) });
  438. }
  439. function updateDevicesFromStanza (stanza) {
  440. const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
  441. if (!items_el) {
  442. return;
  443. }
  444. const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
  445. const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
  446. const jid = stanza.getAttribute('from');
  447. const devicelist = _converse.devicelists.getDeviceList(jid);
  448. const devices = devicelist.devices;
  449. const removed_ids = difference(devices.pluck('id'), device_ids);
  450. removed_ids.forEach(id => {
  451. if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
  452. return; // We don't set the current device as inactive
  453. }
  454. devices.get(id).save('active', false);
  455. });
  456. device_ids.forEach(device_id => {
  457. const device = devices.get(device_id);
  458. if (device) {
  459. device.save('active', true);
  460. } else {
  461. devices.create({ 'id': device_id, 'jid': jid });
  462. }
  463. });
  464. if (u.isSameBareJID(jid, _converse.bare_jid)) {
  465. // Make sure our own device is on the list
  466. // (i.e. if it was removed, add it again).
  467. devicelist.publishCurrentDevice(device_ids);
  468. }
  469. }
  470. export function registerPEPPushHandler () {
  471. // Add a handler for devices pushed from other connected clients
  472. _converse.connection.addHandler(
  473. message => {
  474. try {
  475. if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
  476. updateDevicesFromStanza(message);
  477. updateBundleFromStanza(message);
  478. }
  479. } catch (e) {
  480. log.error(e.message);
  481. }
  482. return true;
  483. },
  484. null,
  485. 'message',
  486. 'headline'
  487. );
  488. }
  489. export function restoreOMEMOSession () {
  490. if (_converse.omemo_store === undefined) {
  491. const id = `converse.omemosession-${_converse.bare_jid}`;
  492. _converse.omemo_store = new _converse.OMEMOStore({ id });
  493. initStorage(_converse.omemo_store, id);
  494. }
  495. return _converse.omemo_store.fetchSession();
  496. }
  497. function fetchDeviceLists () {
  498. return new Promise((success, error) => _converse.devicelists.fetch({ success, 'error': (m, e) => error(e) }));
  499. }
  500. async function fetchOwnDevices () {
  501. await fetchDeviceLists();
  502. let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
  503. if (own_devicelist) {
  504. own_devicelist.fetchDevices();
  505. } else {
  506. own_devicelist = await _converse.devicelists.create({ 'jid': _converse.bare_jid }, { 'promise': true });
  507. }
  508. return own_devicelist._devices_promise;
  509. }
  510. export async function initOMEMO () {
  511. if (!_converse.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
  512. log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true');
  513. return;
  514. }
  515. _converse.devicelists = new _converse.DeviceLists();
  516. const id = `converse.devicelists-${_converse.bare_jid}`;
  517. initStorage(_converse.devicelists, id);
  518. try {
  519. await fetchOwnDevices();
  520. await restoreOMEMOSession();
  521. await _converse.omemo_store.publishBundle();
  522. } catch (e) {
  523. log.error('Could not initialize OMEMO support');
  524. log.error(e);
  525. return;
  526. }
  527. /**
  528. * Triggered once OMEMO support has been initialized
  529. * @event _converse#OMEMOInitialized
  530. * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */
  531. api.trigger('OMEMOInitialized');
  532. }
  533. async function onOccupantAdded (chatroom, occupant) {
  534. if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
  535. return;
  536. }
  537. if (chatroom.get('omemo_active')) {
  538. const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid'));
  539. if (!supported) {
  540. chatroom.createMessage({
  541. 'message': __(
  542. "%1$s doesn't appear to have a client that supports OMEMO. " +
  543. 'Encrypted chat will no longer be possible in this grouchat.',
  544. occupant.get('nick')
  545. ),
  546. 'type': 'error'
  547. });
  548. chatroom.save({ 'omemo_active': false, 'omemo_supported': false });
  549. }
  550. }
  551. }
  552. async function checkOMEMOSupported (chatbox) {
  553. let supported;
  554. if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
  555. await api.waitUntil('OMEMOInitialized');
  556. supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
  557. } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
  558. supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid'));
  559. }
  560. chatbox.set('omemo_supported', supported);
  561. if (supported && api.settings.get('omemo_default')) {
  562. chatbox.set('omemo_active', true);
  563. }
  564. }
  565. function toggleOMEMO (ev) {
  566. ev.stopPropagation();
  567. ev.preventDefault();
  568. const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
  569. if (!toolbar_el.model.get('omemo_supported')) {
  570. let messages;
  571. if (toolbar_el.model.get('type') === _converse.CHATROOMS_TYPE) {
  572. messages = [
  573. __(
  574. 'Cannot use end-to-end encryption in this groupchat, ' +
  575. 'either the groupchat has some anonymity or not all participants support OMEMO.'
  576. )
  577. ];
  578. } else {
  579. messages = [
  580. __(
  581. "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
  582. toolbar_el.model.contact.getDisplayName()
  583. )
  584. ];
  585. }
  586. return api.alert('error', __('Error'), messages);
  587. }
  588. toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') });
  589. }
  590. export function getOMEMOToolbarButton (toolbar_el, buttons) {
  591. const model = toolbar_el.model;
  592. const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
  593. let title;
  594. if (is_muc && model.get('omemo_supported')) {
  595. const i18n_plaintext = __('Messages are being sent in plaintext');
  596. const i18n_encrypted = __('Messages are sent encrypted');
  597. title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
  598. } else {
  599. title = __(
  600. 'This groupchat needs to be members-only and non-anonymous in ' +
  601. 'order to support OMEMO encrypted messages'
  602. );
  603. }
  604. buttons.push(html`
  605. <button class="toggle-omemo" title="${title}" ?disabled=${!model.get('omemo_supported')} @click=${toggleOMEMO}>
  606. <converse-icon
  607. class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
  608. path-prefix="${api.settings.get('assets_path')}"
  609. size="1em"
  610. color="${model.get('omemo_active') ? `var(--info-color)` : `var(--error-color)`}"
  611. ></converse-icon>
  612. </button>
  613. `);
  614. return buttons;
  615. }