|
@@ -3,103 +3,40 @@
|
|
|
* @copyright The Converse.js contributors
|
|
|
* @license Mozilla Public License (MPLv2)
|
|
|
*/
|
|
|
-/* global libsignal */
|
|
|
-
|
|
|
-import '../modals/user-details.js';
|
|
|
-import './profile/index.js';
|
|
|
-import concat from 'lodash-es/concat';
|
|
|
-import debounce from 'lodash-es/debounce';
|
|
|
-import difference from 'lodash-es/difference';
|
|
|
-import invokeMap from 'lodash-es/invokeMap';
|
|
|
+import 'modals/user-details.js';
|
|
|
+import 'plugins/profile/index.js';
|
|
|
+import ChatBox from './overrides/chatbox.js';
|
|
|
+import ConverseMixins from './mixins/converse.js';
|
|
|
+import Device from './device.js';
|
|
|
+import DeviceList from './devicelist.js';
|
|
|
+import DeviceLists from './devicelists.js';
|
|
|
+import Devices from './devices.js';
|
|
|
+import OMEMOStore from './store.js';
|
|
|
+import ProfileModal from './overrides/profile-modal.js';
|
|
|
+import UserDetailsModal from './overrides/user-details-modal.js';
|
|
|
import log from '@converse/headless/log';
|
|
|
-import omit from 'lodash-es/omit';
|
|
|
-import range from 'lodash-es/range';
|
|
|
-import { Collection } from '@converse/skeletor/src/collection';
|
|
|
-import { Model } from '@converse/skeletor/src/model.js';
|
|
|
-import { __ } from '../i18n';
|
|
|
+import omemo_api from './api.js';
|
|
|
+import { OMEMOEnabledChatBox } from './mixins/chatbox.js';
|
|
|
import { _converse, api, converse } from '@converse/headless/core';
|
|
|
import {
|
|
|
- addKeysToMessageStanza,
|
|
|
- generateDeviceID,
|
|
|
- generateFingerprint,
|
|
|
- getDevicesForContact,
|
|
|
getOMEMOToolbarButton,
|
|
|
- getSession,
|
|
|
- getSessionCipher,
|
|
|
initOMEMO,
|
|
|
+ omemo,
|
|
|
onChatBoxesInitialized,
|
|
|
onChatInitialized,
|
|
|
parseEncryptedMessage,
|
|
|
registerPEPPushHandler,
|
|
|
- restoreOMEMOSession,
|
|
|
} from './utils.js';
|
|
|
|
|
|
-const { Strophe, sizzle, $build, $iq, $msg, u } = converse.env;
|
|
|
+const { Strophe } = converse.env;
|
|
|
+
|
|
|
+converse.env.omemo = omemo;
|
|
|
|
|
|
Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO + '.devicelist');
|
|
|
Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO + '.verification');
|
|
|
Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO + '.whitelisted');
|
|
|
Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO + '.bundles');
|
|
|
|
|
|
-const UNDECIDED = 0;
|
|
|
-const TRUSTED = 1; // eslint-disable-line no-unused-vars
|
|
|
-const UNTRUSTED = -1;
|
|
|
-
|
|
|
-class IQError extends Error {
|
|
|
- constructor (message, iq) {
|
|
|
- super(message, iq);
|
|
|
- this.name = 'IQError';
|
|
|
- this.iq = iq;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Mixin object that contains OMEMO-related methods for
|
|
|
- * {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects.
|
|
|
- *
|
|
|
- * @typedef {Object} OMEMOEnabledChatBox
|
|
|
- */
|
|
|
-const OMEMOEnabledChatBox = {
|
|
|
- encryptKey (plaintext, device) {
|
|
|
- return getSessionCipher(device.get('jid'), device.get('id'))
|
|
|
- .encrypt(plaintext)
|
|
|
- .then(payload => ({ 'payload': payload, 'device': device }));
|
|
|
- },
|
|
|
-
|
|
|
- handleMessageSendError (e) {
|
|
|
- if (e.name === 'IQError') {
|
|
|
- this.save('omemo_supported', false);
|
|
|
-
|
|
|
- const err_msgs = [];
|
|
|
- if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
|
|
|
- err_msgs.push(
|
|
|
- __(
|
|
|
- "Sorry, we're unable to send an encrypted message because %1$s " +
|
|
|
- 'requires you to be subscribed to their presence in order to see their OMEMO information',
|
|
|
- e.iq.getAttribute('from')
|
|
|
- )
|
|
|
- );
|
|
|
- } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
|
|
|
- err_msgs.push(
|
|
|
- __(
|
|
|
- "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
|
|
|
- e.iq.getAttribute('from')
|
|
|
- )
|
|
|
- );
|
|
|
- } else {
|
|
|
- err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
|
|
|
- err_msgs.push(e.iq.outerHTML);
|
|
|
- }
|
|
|
- api.alert('error', __('Error'), err_msgs);
|
|
|
- log.error(e);
|
|
|
- } else if (e.user_facing) {
|
|
|
- api.alert('error', __('Error'), [e.message]);
|
|
|
- log.error(e);
|
|
|
- } else {
|
|
|
- throw e;
|
|
|
- }
|
|
|
- }
|
|
|
-};
|
|
|
|
|
|
converse.plugins.add('converse-omemo', {
|
|
|
enabled (_converse) {
|
|
@@ -113,725 +50,23 @@ converse.plugins.add('converse-omemo', {
|
|
|
|
|
|
dependencies: ['converse-chatview', 'converse-pubsub', 'converse-profile'],
|
|
|
|
|
|
- overrides: {
|
|
|
- ProfileModal: {
|
|
|
- events: {
|
|
|
- 'change input.select-all': 'selectAll',
|
|
|
- 'click .generate-bundle': 'generateOMEMODeviceBundle',
|
|
|
- 'submit .fingerprint-removal': 'removeSelectedFingerprints'
|
|
|
- },
|
|
|
-
|
|
|
- initialize () {
|
|
|
- this.debouncedRender = debounce(this.render, 50);
|
|
|
- this.devicelist = _converse.devicelists.get(_converse.bare_jid);
|
|
|
- this.listenTo(this.devicelist.devices, 'change:bundle', this.debouncedRender);
|
|
|
- this.listenTo(this.devicelist.devices, 'reset', this.debouncedRender);
|
|
|
- this.listenTo(this.devicelist.devices, 'reset', this.debouncedRender);
|
|
|
- this.listenTo(this.devicelist.devices, 'remove', this.debouncedRender);
|
|
|
- this.listenTo(this.devicelist.devices, 'add', this.debouncedRender);
|
|
|
- return this.__super__.initialize.apply(this, arguments);
|
|
|
- },
|
|
|
-
|
|
|
- beforeRender () {
|
|
|
- const device_id = _converse.omemo_store.get('device_id');
|
|
|
-
|
|
|
- if (device_id) {
|
|
|
- this.current_device = this.devicelist.devices.get(device_id);
|
|
|
- }
|
|
|
- this.other_devices = this.devicelist.devices.filter(d => d.get('id') !== device_id);
|
|
|
- if (this.__super__.beforeRender) {
|
|
|
- return this.__super__.beforeRender.apply(this, arguments);
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- selectAll (ev) {
|
|
|
- let sibling = u.ancestor(ev.target, 'li');
|
|
|
- while (sibling) {
|
|
|
- sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked;
|
|
|
- sibling = sibling.nextElementSibling;
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- removeSelectedFingerprints (ev) {
|
|
|
- ev.preventDefault();
|
|
|
- ev.stopPropagation();
|
|
|
- ev.target.querySelector('.select-all').checked = false;
|
|
|
- const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map(
|
|
|
- c => c.value
|
|
|
- );
|
|
|
- this.devicelist
|
|
|
- .removeOwnDevices(device_ids)
|
|
|
- .then(this.modal.hide)
|
|
|
- .catch(err => {
|
|
|
- log.error(err);
|
|
|
- _converse.api.alert(Strophe.LogLevel.ERROR, __('Error'), [
|
|
|
- __('Sorry, an error occurred while trying to remove the devices.')
|
|
|
- ]);
|
|
|
- });
|
|
|
- },
|
|
|
-
|
|
|
- generateOMEMODeviceBundle (ev) {
|
|
|
- ev.preventDefault();
|
|
|
- if (
|
|
|
- confirm(
|
|
|
- __(
|
|
|
- 'Are you sure you want to generate new OMEMO keys? ' +
|
|
|
- 'This will remove your old keys and all previously encrypted messages will no longer be decryptable on this device.'
|
|
|
- )
|
|
|
- )
|
|
|
- ) {
|
|
|
- api.omemo.bundle.generate();
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- UserDetailsModal: {
|
|
|
- events: {
|
|
|
- 'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
|
|
|
- },
|
|
|
-
|
|
|
- initialize () {
|
|
|
- const jid = this.model.get('jid');
|
|
|
- this.devicelist = _converse.devicelists.getDeviceList(jid);
|
|
|
- this.listenTo(this.devicelist.devices, 'change:bundle', this.render);
|
|
|
- this.listenTo(this.devicelist.devices, 'change:trusted', this.render);
|
|
|
- this.listenTo(this.devicelist.devices, 'remove', this.render);
|
|
|
- this.listenTo(this.devicelist.devices, 'add', this.render);
|
|
|
- this.listenTo(this.devicelist.devices, 'reset', this.render);
|
|
|
- return this.__super__.initialize.apply(this, arguments);
|
|
|
- },
|
|
|
-
|
|
|
- toggleDeviceTrust (ev) {
|
|
|
- const radio = ev.target;
|
|
|
- const device = this.devicelist.devices.get(radio.getAttribute('name'));
|
|
|
- device.save('trusted', parseInt(radio.value, 10));
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- ChatBox: {
|
|
|
- async sendMessage (text, spoiler_hint) {
|
|
|
- if (this.get('omemo_active') && text) {
|
|
|
- const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
|
|
|
- attrs['is_encrypted'] = true;
|
|
|
- attrs['plaintext'] = attrs.message;
|
|
|
- let message, stanza;
|
|
|
- try {
|
|
|
- const devices = await _converse.getBundlesAndBuildSessions(this);
|
|
|
- message = await this.createMessage(attrs);
|
|
|
- stanza = await _converse.createOMEMOMessageStanza(this, message, devices);
|
|
|
- } catch (e) {
|
|
|
- this.handleMessageSendError(e);
|
|
|
- return null;
|
|
|
- }
|
|
|
- _converse.api.send(stanza);
|
|
|
- return message;
|
|
|
- } else {
|
|
|
- return this.__super__.sendMessage.apply(this, arguments);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
+ overrides: { ProfileModal, UserDetailsModal, ChatBox },
|
|
|
|
|
|
initialize () {
|
|
|
- /* The initialize function gets called as soon as the plugin is
|
|
|
- * loaded by Converse.js's plugin machinery.
|
|
|
- */
|
|
|
-
|
|
|
api.settings.extend({ 'omemo_default': false });
|
|
|
api.promises.add(['OMEMOInitialized']);
|
|
|
|
|
|
_converse.NUM_PREKEYS = 100; // Set here so that tests can override
|
|
|
|
|
|
Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox);
|
|
|
+ Object.assign(_converse, ConverseMixins);
|
|
|
+ Object.assign(_converse.api, omemo_api);
|
|
|
|
|
|
- _converse.generateFingerprints = async function (jid) {
|
|
|
- const devices = await getDevicesForContact(jid);
|
|
|
- return Promise.all(devices.map(d => generateFingerprint(d)));
|
|
|
- };
|
|
|
-
|
|
|
- _converse.getDeviceForContact = function (jid, device_id) {
|
|
|
- return getDevicesForContact(jid).then(devices => devices.get(device_id));
|
|
|
- };
|
|
|
-
|
|
|
- _converse.contactHasOMEMOSupport = async function (jid) {
|
|
|
- /* Checks whether the contact advertises any OMEMO-compatible devices. */
|
|
|
- const devices = await getDevicesForContact(jid);
|
|
|
- return devices.length > 0;
|
|
|
- };
|
|
|
-
|
|
|
- _converse.getBundlesAndBuildSessions = async function (chatbox) {
|
|
|
- const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
|
|
|
- let devices;
|
|
|
- if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
- const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
|
|
|
- devices = collections.reduce((a, b) => concat(a, b.models), []);
|
|
|
- } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
|
|
|
- const their_devices = await getDevicesForContact(chatbox.get('jid'));
|
|
|
- if (their_devices.length === 0) {
|
|
|
- const err = new Error(no_devices_err);
|
|
|
- err.user_facing = true;
|
|
|
- throw err;
|
|
|
- }
|
|
|
- const own_devices = _converse.devicelists.get(_converse.bare_jid).devices;
|
|
|
- devices = [...own_devices.models, ...their_devices.models];
|
|
|
- }
|
|
|
- // Filter out our own device
|
|
|
- const id = _converse.omemo_store.get('device_id');
|
|
|
- devices = devices.filter(d => d.get('id') !== id);
|
|
|
- // Fetch bundles if necessary
|
|
|
- await Promise.all(devices.map(d => d.getBundle()));
|
|
|
-
|
|
|
- const sessions = devices.filter(d => d).map(d => getSession(d));
|
|
|
- await Promise.all(sessions);
|
|
|
- if (sessions.includes(null)) {
|
|
|
- // We couldn't build a session for certain devices.
|
|
|
- devices = devices.filter(d => sessions[devices.indexOf(d)]);
|
|
|
- if (devices.length === 0) {
|
|
|
- const err = new Error(no_devices_err);
|
|
|
- err.user_facing = true;
|
|
|
- throw err;
|
|
|
- }
|
|
|
- }
|
|
|
- return devices;
|
|
|
- };
|
|
|
-
|
|
|
- _converse.createOMEMOMessageStanza = function (chatbox, message, devices) {
|
|
|
- const body = __(
|
|
|
- 'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
|
|
|
- 'Find more information on https://conversations.im/omemo'
|
|
|
- );
|
|
|
-
|
|
|
- if (!message.get('message')) {
|
|
|
- throw new Error('No message body to encrypt!');
|
|
|
- }
|
|
|
- const stanza = $msg({
|
|
|
- 'from': _converse.connection.jid,
|
|
|
- 'to': chatbox.get('jid'),
|
|
|
- 'type': chatbox.get('message_type'),
|
|
|
- 'id': message.get('msgid')
|
|
|
- })
|
|
|
- .c('body')
|
|
|
- .t(body)
|
|
|
- .up();
|
|
|
-
|
|
|
- if (message.get('type') === 'chat') {
|
|
|
- stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).up();
|
|
|
- }
|
|
|
- // An encrypted header is added to the message for
|
|
|
- // each device that is supposed to receive it.
|
|
|
- // These headers simply contain the key that the
|
|
|
- // payload message is encrypted with,
|
|
|
- // and they are separately encrypted using the
|
|
|
- // session corresponding to the counterpart device.
|
|
|
- stanza
|
|
|
- .c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
|
|
|
- .c('header', { 'sid': _converse.omemo_store.get('device_id') });
|
|
|
-
|
|
|
- return omemo.encryptMessage(message.get('message')).then(obj => {
|
|
|
- // The 16 bytes key and the GCM authentication tag (The tag
|
|
|
- // SHOULD have at least 128 bit) are concatenated and for each
|
|
|
- // intended recipient device, i.e. both own devices as well as
|
|
|
- // devices associated with the contact, the result of this
|
|
|
- // concatenation is encrypted using the corresponding
|
|
|
- // long-standing SignalProtocol session.
|
|
|
- const promises = devices
|
|
|
- .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
|
|
|
- .map(device => chatbox.encryptKey(obj.key_and_tag, device));
|
|
|
-
|
|
|
- return Promise.all(promises)
|
|
|
- .then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv))
|
|
|
- .then(stanza => {
|
|
|
- stanza
|
|
|
- .c('payload')
|
|
|
- .t(obj.payload)
|
|
|
- .up()
|
|
|
- .up();
|
|
|
- stanza.c('store', { 'xmlns': Strophe.NS.HINTS });
|
|
|
- return stanza;
|
|
|
- });
|
|
|
- });
|
|
|
- };
|
|
|
-
|
|
|
- _converse.OMEMOStore = Model.extend({
|
|
|
- Direction: {
|
|
|
- SENDING: 1,
|
|
|
- RECEIVING: 2
|
|
|
- },
|
|
|
-
|
|
|
- getIdentityKeyPair () {
|
|
|
- const keypair = this.get('identity_keypair');
|
|
|
- return Promise.resolve({
|
|
|
- 'privKey': u.base64ToArrayBuffer(keypair.privKey),
|
|
|
- 'pubKey': u.base64ToArrayBuffer(keypair.pubKey)
|
|
|
- });
|
|
|
- },
|
|
|
-
|
|
|
- getLocalRegistrationId () {
|
|
|
- return Promise.resolve(parseInt(this.get('device_id'), 10));
|
|
|
- },
|
|
|
-
|
|
|
- isTrustedIdentity (identifier, identity_key, direction) {
|
|
|
- // eslint-disable-line no-unused-vars
|
|
|
- if (identifier === null || identifier === undefined) {
|
|
|
- throw new Error("Can't check identity key for invalid key");
|
|
|
- }
|
|
|
- if (!(identity_key instanceof ArrayBuffer)) {
|
|
|
- throw new Error('Expected identity_key to be an ArrayBuffer');
|
|
|
- }
|
|
|
- const trusted = this.get('identity_key' + identifier);
|
|
|
- if (trusted === undefined) {
|
|
|
- return Promise.resolve(true);
|
|
|
- }
|
|
|
- return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
|
|
|
- },
|
|
|
-
|
|
|
- loadIdentityKey (identifier) {
|
|
|
- if (identifier === null || identifier === undefined) {
|
|
|
- throw new Error("Can't load identity_key for invalid identifier");
|
|
|
- }
|
|
|
- return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier)));
|
|
|
- },
|
|
|
-
|
|
|
- saveIdentity (identifier, identity_key) {
|
|
|
- if (identifier === null || identifier === undefined) {
|
|
|
- throw new Error("Can't save identity_key for invalid identifier");
|
|
|
- }
|
|
|
- const address = new libsignal.SignalProtocolAddress.fromString(identifier);
|
|
|
- const existing = this.get('identity_key' + address.getName());
|
|
|
- const b64_idkey = u.arrayBufferToBase64(identity_key);
|
|
|
- this.save('identity_key' + address.getName(), b64_idkey);
|
|
|
-
|
|
|
- if (existing && b64_idkey !== existing) {
|
|
|
- return Promise.resolve(true);
|
|
|
- } else {
|
|
|
- return Promise.resolve(false);
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- getPreKeys () {
|
|
|
- return this.get('prekeys') || {};
|
|
|
- },
|
|
|
-
|
|
|
- loadPreKey (key_id) {
|
|
|
- const res = this.getPreKeys()[key_id];
|
|
|
- if (res) {
|
|
|
- return Promise.resolve({
|
|
|
- 'privKey': u.base64ToArrayBuffer(res.privKey),
|
|
|
- 'pubKey': u.base64ToArrayBuffer(res.pubKey)
|
|
|
- });
|
|
|
- }
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- storePreKey (key_id, key_pair) {
|
|
|
- const prekey = {};
|
|
|
- prekey[key_id] = {
|
|
|
- 'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
|
|
|
- 'privKey': u.arrayBufferToBase64(key_pair.privKey)
|
|
|
- };
|
|
|
- this.save('prekeys', Object.assign(this.getPreKeys(), prekey));
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- removePreKey (key_id) {
|
|
|
- this.save('prekeys', omit(this.getPreKeys(), key_id));
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- loadSignedPreKey (keyId) {
|
|
|
- // eslint-disable-line no-unused-vars
|
|
|
- const res = this.get('signed_prekey');
|
|
|
- if (res) {
|
|
|
- return Promise.resolve({
|
|
|
- 'privKey': u.base64ToArrayBuffer(res.privKey),
|
|
|
- 'pubKey': u.base64ToArrayBuffer(res.pubKey)
|
|
|
- });
|
|
|
- }
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- storeSignedPreKey (spk) {
|
|
|
- if (typeof spk !== 'object') {
|
|
|
- // XXX: We've changed the signature of this method from the
|
|
|
- // example given in InMemorySignalProtocolStore.
|
|
|
- // Should be fine because the libsignal code doesn't
|
|
|
- // actually call this method.
|
|
|
- throw new Error('storeSignedPreKey: expected an object');
|
|
|
- }
|
|
|
- this.save('signed_prekey', {
|
|
|
- 'id': spk.keyId,
|
|
|
- 'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
|
|
|
- 'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
|
|
|
- // XXX: The InMemorySignalProtocolStore does not pass
|
|
|
- // in or store the signature, but we need it when we
|
|
|
- // publish out bundle and this method isn't called from
|
|
|
- // within libsignal code, so we modify it to also store
|
|
|
- // the signature.
|
|
|
- 'signature': u.arrayBufferToBase64(spk.signature)
|
|
|
- });
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- removeSignedPreKey (key_id) {
|
|
|
- if (this.get('signed_prekey')['id'] === key_id) {
|
|
|
- this.unset('signed_prekey');
|
|
|
- this.save();
|
|
|
- }
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- loadSession (identifier) {
|
|
|
- return Promise.resolve(this.get('session' + identifier));
|
|
|
- },
|
|
|
-
|
|
|
- storeSession (identifier, record) {
|
|
|
- return Promise.resolve(this.save('session' + identifier, record));
|
|
|
- },
|
|
|
-
|
|
|
- removeSession (identifier) {
|
|
|
- return Promise.resolve(this.unset('session' + identifier));
|
|
|
- },
|
|
|
-
|
|
|
- removeAllSessions (identifier) {
|
|
|
- const keys = Object.keys(this.attributes).filter(key =>
|
|
|
- key.startsWith('session' + identifier) ? key : false
|
|
|
- );
|
|
|
- const attrs = {};
|
|
|
- keys.forEach(key => {
|
|
|
- attrs[key] = undefined;
|
|
|
- });
|
|
|
- this.save(attrs);
|
|
|
- return Promise.resolve();
|
|
|
- },
|
|
|
-
|
|
|
- publishBundle () {
|
|
|
- const signed_prekey = this.get('signed_prekey');
|
|
|
- const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`;
|
|
|
- const item = $build('item')
|
|
|
- .c('bundle', { 'xmlns': Strophe.NS.OMEMO })
|
|
|
- .c('signedPreKeyPublic', { 'signedPreKeyId': signed_prekey.id })
|
|
|
- .t(signed_prekey.pubKey)
|
|
|
- .up()
|
|
|
- .c('signedPreKeySignature')
|
|
|
- .t(signed_prekey.signature)
|
|
|
- .up()
|
|
|
- .c('identityKey')
|
|
|
- .t(this.get('identity_keypair').pubKey)
|
|
|
- .up()
|
|
|
- .c('prekeys');
|
|
|
-
|
|
|
- Object.values(this.get('prekeys')).forEach((prekey, id) =>
|
|
|
- item
|
|
|
- .c('preKeyPublic', { 'preKeyId': id })
|
|
|
- .t(prekey.pubKey)
|
|
|
- .up()
|
|
|
- );
|
|
|
- const options = { 'pubsub#access_model': 'open' };
|
|
|
- return api.pubsub.publish(null, node, item, options, false);
|
|
|
- },
|
|
|
-
|
|
|
- async generateMissingPreKeys () {
|
|
|
- const missing_keys = difference(
|
|
|
- invokeMap(range(0, _converse.NUM_PREKEYS), Number.prototype.toString),
|
|
|
- Object.keys(this.getPreKeys())
|
|
|
- );
|
|
|
- if (missing_keys.length < 1) {
|
|
|
- log.warn('No missing prekeys to generate for our own device');
|
|
|
- return Promise.resolve();
|
|
|
- }
|
|
|
- const keys = await Promise.all(
|
|
|
- missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))
|
|
|
- );
|
|
|
- keys.forEach(k => this.storePreKey(k.keyId, k.keyPair));
|
|
|
- const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({
|
|
|
- 'id': k.keyId,
|
|
|
- 'key': u.arrayBufferToBase64(k.pubKey)
|
|
|
- }));
|
|
|
- const devicelist = _converse.devicelists.get(_converse.bare_jid);
|
|
|
- const device = devicelist.devices.get(this.get('device_id'));
|
|
|
- const bundle = await device.getBundle();
|
|
|
- device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys }));
|
|
|
- },
|
|
|
-
|
|
|
- /**
|
|
|
- * Generate a the data used by the X3DH key agreement protocol
|
|
|
- * that can be used to build a session with a device.
|
|
|
- */
|
|
|
- async generateBundle () {
|
|
|
- // The first thing that needs to happen if a client wants to
|
|
|
- // start using OMEMO is they need to generate an IdentityKey
|
|
|
- // and a Device ID. The IdentityKey is a Curve25519 [6]
|
|
|
- // public/private Key pair. The Device ID is a randomly
|
|
|
- // generated integer between 1 and 2^31 - 1.
|
|
|
- const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
|
|
|
- const bundle = {};
|
|
|
- const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
|
|
|
- const device_id = generateDeviceID();
|
|
|
-
|
|
|
- bundle['identity_key'] = identity_key;
|
|
|
- bundle['device_id'] = device_id;
|
|
|
- this.save({
|
|
|
- 'device_id': device_id,
|
|
|
- 'identity_keypair': {
|
|
|
- 'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
|
|
|
- 'pubKey': identity_key
|
|
|
- },
|
|
|
- 'identity_key': identity_key
|
|
|
- });
|
|
|
- const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
|
|
|
-
|
|
|
- _converse.omemo_store.storeSignedPreKey(signed_prekey);
|
|
|
- bundle['signed_prekey'] = {
|
|
|
- 'id': signed_prekey.keyId,
|
|
|
- 'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.privKey),
|
|
|
- 'signature': u.arrayBufferToBase64(signed_prekey.signature)
|
|
|
- };
|
|
|
- const keys = await Promise.all(
|
|
|
- range(0, _converse.NUM_PREKEYS).map(id => libsignal.KeyHelper.generatePreKey(id))
|
|
|
- );
|
|
|
- keys.forEach(k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
|
|
|
- const devicelist = _converse.devicelists.get(_converse.bare_jid);
|
|
|
- const device = await devicelist.devices.create(
|
|
|
- { 'id': bundle.device_id, 'jid': _converse.bare_jid },
|
|
|
- { 'promise': true }
|
|
|
- );
|
|
|
- const marshalled_keys = keys.map(k => ({
|
|
|
- 'id': k.keyId,
|
|
|
- 'key': u.arrayBufferToBase64(k.keyPair.pubKey)
|
|
|
- }));
|
|
|
- bundle['prekeys'] = marshalled_keys;
|
|
|
- device.save('bundle', bundle);
|
|
|
- },
|
|
|
-
|
|
|
- fetchSession () {
|
|
|
- if (this._setup_promise === undefined) {
|
|
|
- this._setup_promise = new Promise((resolve, reject) => {
|
|
|
- this.fetch({
|
|
|
- 'success': () => {
|
|
|
- if (!_converse.omemo_store.get('device_id')) {
|
|
|
- this.generateBundle()
|
|
|
- .then(resolve)
|
|
|
- .catch(reject);
|
|
|
- } else {
|
|
|
- resolve();
|
|
|
- }
|
|
|
- },
|
|
|
- 'error': (model, resp) => {
|
|
|
- log.warn("Could not fetch OMEMO session from cache, we'll generate a new one.");
|
|
|
- log.warn(resp);
|
|
|
- this.generateBundle()
|
|
|
- .then(resolve)
|
|
|
- .catch(reject);
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- }
|
|
|
- return this._setup_promise;
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- /**
|
|
|
- * @class
|
|
|
- * @namespace _converse.Device
|
|
|
- * @memberOf _converse
|
|
|
- */
|
|
|
- _converse.Device = Model.extend({
|
|
|
- defaults: {
|
|
|
- 'trusted': UNDECIDED,
|
|
|
- 'active': true
|
|
|
- },
|
|
|
-
|
|
|
- getRandomPreKey () {
|
|
|
- // XXX: assumes that the bundle has already been fetched
|
|
|
- const bundle = this.get('bundle');
|
|
|
- return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
|
|
|
- },
|
|
|
-
|
|
|
- async fetchBundleFromServer () {
|
|
|
- const stanza = $iq({
|
|
|
- 'type': 'get',
|
|
|
- 'from': _converse.bare_jid,
|
|
|
- 'to': this.get('jid')
|
|
|
- })
|
|
|
- .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
|
|
|
- .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
|
|
|
-
|
|
|
- let iq;
|
|
|
- try {
|
|
|
- iq = await api.sendIQ(stanza);
|
|
|
- } catch (iq) {
|
|
|
- log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`);
|
|
|
- log.error(iq);
|
|
|
- return null;
|
|
|
- }
|
|
|
- if (iq.querySelector('error')) {
|
|
|
- throw new IQError('Could not fetch bundle', iq);
|
|
|
- }
|
|
|
- const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop();
|
|
|
- const bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop();
|
|
|
- const bundle = parseBundle(bundle_el);
|
|
|
- this.save('bundle', bundle);
|
|
|
- return bundle;
|
|
|
- },
|
|
|
-
|
|
|
- /**
|
|
|
- * Fetch and save the bundle information associated with
|
|
|
- * this device, if the information is not cached already.
|
|
|
- * @method _converse.Device#getBundle
|
|
|
- */
|
|
|
- getBundle () {
|
|
|
- if (this.get('bundle')) {
|
|
|
- return Promise.resolve(this.get('bundle'), this);
|
|
|
- } else {
|
|
|
- return this.fetchBundleFromServer();
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- _converse.Devices = Collection.extend({
|
|
|
- model: _converse.Device
|
|
|
- });
|
|
|
-
|
|
|
- /**
|
|
|
- * @class
|
|
|
- * @namespace _converse.DeviceList
|
|
|
- * @memberOf _converse
|
|
|
- */
|
|
|
- _converse.DeviceList = Model.extend({
|
|
|
- idAttribute: 'jid',
|
|
|
-
|
|
|
- initialize () {
|
|
|
- this.devices = new _converse.Devices();
|
|
|
- const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
|
|
|
- this.devices.browserStorage = _converse.createStore(id);
|
|
|
- this.fetchDevices();
|
|
|
- },
|
|
|
-
|
|
|
- async onDevicesFound (collection) {
|
|
|
- if (collection.length === 0) {
|
|
|
- let ids;
|
|
|
- try {
|
|
|
- ids = await this.fetchDevicesFromServer();
|
|
|
- } catch (e) {
|
|
|
- if (e === null) {
|
|
|
- log.error(`Timeout error while fetching devices for ${this.get('jid')}`);
|
|
|
- } else {
|
|
|
- log.error(`Could not fetch devices for ${this.get('jid')}`);
|
|
|
- log.error(e);
|
|
|
- }
|
|
|
- this.destroy();
|
|
|
- }
|
|
|
- if (this.get('jid') === _converse.bare_jid) {
|
|
|
- await this.publishCurrentDevice(ids);
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- fetchDevices () {
|
|
|
- if (this._devices_promise === undefined) {
|
|
|
- this._devices_promise = new Promise(resolve => {
|
|
|
- this.devices.fetch({
|
|
|
- 'success': c => resolve(this.onDevicesFound(c)),
|
|
|
- 'error': (m, e) => {
|
|
|
- log.error(e);
|
|
|
- resolve();
|
|
|
- }
|
|
|
- });
|
|
|
- });
|
|
|
- }
|
|
|
- return this._devices_promise;
|
|
|
- },
|
|
|
-
|
|
|
- async getOwnDeviceId () {
|
|
|
- let device_id = _converse.omemo_store.get('device_id');
|
|
|
- if (!this.devices.findWhere({ 'id': device_id })) {
|
|
|
- // Generate a new bundle if we cannot find our device
|
|
|
- await _converse.omemo_store.generateBundle();
|
|
|
- device_id = _converse.omemo_store.get('device_id');
|
|
|
- }
|
|
|
- return device_id;
|
|
|
- },
|
|
|
-
|
|
|
- async publishCurrentDevice (device_ids) {
|
|
|
- if (this.get('jid') !== _converse.bare_jid) {
|
|
|
- return; // We only publish for ourselves.
|
|
|
- }
|
|
|
- await restoreOMEMOSession();
|
|
|
-
|
|
|
- if (!_converse.omemo_store) {
|
|
|
- // Happens during tests. The connection gets torn down
|
|
|
- // before publishCurrentDevice has time to finish.
|
|
|
- log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
|
|
|
- return;
|
|
|
- }
|
|
|
- if (!device_ids.includes(await this.getOwnDeviceId())) {
|
|
|
- return this.publishDevices();
|
|
|
- }
|
|
|
- },
|
|
|
-
|
|
|
- async fetchDevicesFromServer () {
|
|
|
- const stanza = $iq({
|
|
|
- 'type': 'get',
|
|
|
- 'from': _converse.bare_jid,
|
|
|
- 'to': this.get('jid')
|
|
|
- })
|
|
|
- .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
|
|
|
- .c('items', { 'node': Strophe.NS.OMEMO_DEVICELIST });
|
|
|
-
|
|
|
- let iq;
|
|
|
- try {
|
|
|
- iq = await api.sendIQ(stanza);
|
|
|
- } catch (e) {
|
|
|
- log.error(e);
|
|
|
- return [];
|
|
|
- }
|
|
|
- const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`;
|
|
|
- const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id'));
|
|
|
- await Promise.all(
|
|
|
- device_ids.map(id => this.devices.create({ id, 'jid': this.get('jid') }, { 'promise': true }))
|
|
|
- );
|
|
|
- return device_ids;
|
|
|
- },
|
|
|
-
|
|
|
- /**
|
|
|
- * Send an IQ stanza to the current user's "devices" PEP node to
|
|
|
- * ensure that all devices are published for potential chat partners to see.
|
|
|
- * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing
|
|
|
- */
|
|
|
- publishDevices () {
|
|
|
- const item = $build('item', { 'id': 'current' }).c('list', { 'xmlns': Strophe.NS.OMEMO });
|
|
|
- this.devices.filter(d => d.get('active')).forEach(d => item.c('device', { 'id': d.get('id') }).up());
|
|
|
- const options = { 'pubsub#access_model': 'open' };
|
|
|
- return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false);
|
|
|
- },
|
|
|
-
|
|
|
- removeOwnDevices (device_ids) {
|
|
|
- if (this.get('jid') !== _converse.bare_jid) {
|
|
|
- throw new Error("Cannot remove devices from someone else's device list");
|
|
|
- }
|
|
|
- device_ids.forEach(device_id => this.devices.get(device_id).destroy());
|
|
|
- return this.publishDevices();
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- /**
|
|
|
- * @class
|
|
|
- * @namespace _converse.DeviceLists
|
|
|
- * @memberOf _converse
|
|
|
- */
|
|
|
- _converse.DeviceLists = Collection.extend({
|
|
|
- model: _converse.DeviceList,
|
|
|
- /**
|
|
|
- * Returns the {@link _converse.DeviceList} for a particular JID.
|
|
|
- * The device list will be created if it doesn't exist already.
|
|
|
- * @private
|
|
|
- * @method _converse.DeviceLists#getDeviceList
|
|
|
- * @param { String } jid - The Jabber ID for which the device list will be returned.
|
|
|
- */
|
|
|
- getDeviceList (jid) {
|
|
|
- return this.get(jid) || this.create({ 'jid': jid });
|
|
|
- }
|
|
|
- });
|
|
|
+ _converse.OMEMOStore = OMEMOStore;
|
|
|
+ _converse.Device = Device;
|
|
|
+ _converse.Devices = Devices;
|
|
|
+ _converse.DeviceList = DeviceList;
|
|
|
+ _converse.DeviceLists = DeviceLists;
|
|
|
|
|
|
/******************** Event Handlers ********************/
|
|
|
api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
|
|
@@ -865,54 +100,5 @@ converse.plugins.add('converse-omemo', {
|
|
|
delete _converse.devicelists;
|
|
|
}
|
|
|
});
|
|
|
-
|
|
|
- /************************ API ************************/
|
|
|
- Object.assign(_converse.api, {
|
|
|
- /**
|
|
|
- * The "omemo" namespace groups methods relevant to OMEMO
|
|
|
- * encryption.
|
|
|
- *
|
|
|
- * @namespace _converse.api.omemo
|
|
|
- * @memberOf _converse.api
|
|
|
- */
|
|
|
- 'omemo': {
|
|
|
- /**
|
|
|
- * The "bundle" namespace groups methods relevant to the user's
|
|
|
- * OMEMO bundle.
|
|
|
- *
|
|
|
- * @namespace _converse.api.omemo.bundle
|
|
|
- * @memberOf _converse.api.omemo
|
|
|
- */
|
|
|
- 'bundle': {
|
|
|
- /**
|
|
|
- * Lets you generate a new OMEMO device bundle
|
|
|
- *
|
|
|
- * @method _converse.api.omemo.bundle.generate
|
|
|
- * @returns {promise} Promise which resolves once we have a result from the server.
|
|
|
- */
|
|
|
- 'generate': async () => {
|
|
|
- // Remove current device
|
|
|
- const devicelist = _converse.devicelists.get(_converse.bare_jid);
|
|
|
- const device_id = _converse.omemo_store.get('device_id');
|
|
|
- if (device_id) {
|
|
|
- const device = devicelist.devices.get(device_id);
|
|
|
- _converse.omemo_store.unset(device_id);
|
|
|
- if (device) {
|
|
|
- await new Promise(done => device.destroy({ 'success': done, 'error': done }));
|
|
|
- }
|
|
|
- devicelist.devices.trigger('remove');
|
|
|
- }
|
|
|
- // Generate new device bundle and publish
|
|
|
- // https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing
|
|
|
- await _converse.omemo_store.generateBundle();
|
|
|
- await devicelist.publishDevices();
|
|
|
- const device = devicelist.devices.get(_converse.omemo_store.get('device_id'));
|
|
|
- const fp = generateFingerprint(device);
|
|
|
- await _converse.omemo_store.publishBundle();
|
|
|
- return fp;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
}
|
|
|
});
|