utils.js 21 KB

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