utils.js 34 KB

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