Split omemo plugin into more files
This commit is contained in:
parent
42581b1d12
commit
13e19eb7f8
50
src/plugins/omemo/api.js
Normal file
50
src/plugins/omemo/api.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { _converse } from '@converse/headless/core';
|
||||
import { generateFingerprint } from './utils.js';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
src/plugins/omemo/consts.js
Normal file
3
src/plugins/omemo/consts.js
Normal file
@ -0,0 +1,3 @@
|
||||
export const UNDECIDED = 0;
|
||||
export const TRUSTED = 1;
|
||||
export const UNTRUSTED = -1;
|
69
src/plugins/omemo/device.js
Normal file
69
src/plugins/omemo/device.js
Normal file
@ -0,0 +1,69 @@
|
||||
import log from '@converse/headless/log';
|
||||
import { IQError } from './errors.js';
|
||||
import { Model } from '@converse/skeletor/src/model.js';
|
||||
import { UNDECIDED } from './consts.js';
|
||||
import { _converse, api, converse } from '@converse/headless/core';
|
||||
import { parseBundle } from './utils.js';
|
||||
|
||||
const { Strophe, sizzle, u, $iq } = converse.env;
|
||||
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @namespace _converse.Device
|
||||
* @memberOf _converse
|
||||
*/
|
||||
const 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default Device;
|
130
src/plugins/omemo/devicelist.js
Normal file
130
src/plugins/omemo/devicelist.js
Normal file
@ -0,0 +1,130 @@
|
||||
import log from '@converse/headless/log';
|
||||
import { Model } from '@converse/skeletor/src/model.js';
|
||||
import { _converse, api, converse } from '@converse/headless/core';
|
||||
import { restoreOMEMOSession } from './utils.js';
|
||||
|
||||
const { Strophe, $build, $iq, sizzle } = converse.env;
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @namespace _converse.DeviceList
|
||||
* @memberOf _converse
|
||||
*/
|
||||
const 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();
|
||||
}
|
||||
});
|
||||
|
||||
export default DeviceList;
|
24
src/plugins/omemo/devicelists.js
Normal file
24
src/plugins/omemo/devicelists.js
Normal file
@ -0,0 +1,24 @@
|
||||
import DeviceList from './devicelist.js';
|
||||
import { Collection } from '@converse/skeletor/src/collection';
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @namespace _converse.DeviceLists
|
||||
* @memberOf _converse
|
||||
*/
|
||||
const DeviceLists = Collection.extend({
|
||||
model: 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 });
|
||||
}
|
||||
});
|
||||
|
||||
export default DeviceLists;
|
4
src/plugins/omemo/devices.js
Normal file
4
src/plugins/omemo/devices.js
Normal file
@ -0,0 +1,4 @@
|
||||
import Device from './device.js';
|
||||
import { Collection } from '@converse/skeletor/src/collection';
|
||||
|
||||
export default Collection.extend({ model: Device });
|
7
src/plugins/omemo/errors.js
Normal file
7
src/plugins/omemo/errors.js
Normal file
@ -0,0 +1,7 @@
|
||||
export class IQError extends Error {
|
||||
constructor (message, iq) {
|
||||
super(message, iq);
|
||||
this.name = 'IQError';
|
||||
this.iq = iq;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
55
src/plugins/omemo/mixins/chatbox.js
Normal file
55
src/plugins/omemo/mixins/chatbox.js
Normal file
@ -0,0 +1,55 @@
|
||||
import log from '@converse/headless/log';
|
||||
import { __ } from 'i18n';
|
||||
import { api, converse } from '@converse/headless/core';
|
||||
import { getSessionCipher } from '../utils.js';
|
||||
|
||||
const { Strophe, sizzle } = converse.env;
|
||||
|
||||
/**
|
||||
* Mixin object that contains OMEMO-related methods for
|
||||
* {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects.
|
||||
*
|
||||
* @typedef {Object} OMEMOEnabledChatBox
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
119
src/plugins/omemo/mixins/converse.js
Normal file
119
src/plugins/omemo/mixins/converse.js
Normal file
@ -0,0 +1,119 @@
|
||||
import concat from 'lodash-es/concat';
|
||||
import { UNTRUSTED } from '../consts.js';
|
||||
import { __ } from 'i18n';
|
||||
import { _converse, converse } from '@converse/headless/core';
|
||||
import {
|
||||
addKeysToMessageStanza,
|
||||
generateFingerprint,
|
||||
getDevicesForContact,
|
||||
getSession,
|
||||
omemo,
|
||||
} from '../utils.js';
|
||||
|
||||
const { Strophe, $msg } = converse.env;
|
||||
|
||||
const ConverseMixins = {
|
||||
|
||||
generateFingerprints: async function (jid) {
|
||||
const devices = await getDevicesForContact(jid);
|
||||
return Promise.all(devices.map(d => generateFingerprint(d)));
|
||||
},
|
||||
|
||||
getDeviceForContact: function (jid, device_id) {
|
||||
return getDevicesForContact(jid).then(devices => devices.get(device_id));
|
||||
},
|
||||
|
||||
contactHasOMEMOSupport: async function (jid) {
|
||||
/* Checks whether the contact advertises any OMEMO-compatible devices. */
|
||||
const devices = await getDevicesForContact(jid);
|
||||
return devices.length > 0;
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ConverseMixins;
|
26
src/plugins/omemo/overrides/chatbox.js
Normal file
26
src/plugins/omemo/overrides/chatbox.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { _converse } from '@converse/headless/core';
|
||||
|
||||
const 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatBox;
|
76
src/plugins/omemo/overrides/profile-modal.js
Normal file
76
src/plugins/omemo/overrides/profile-modal.js
Normal file
@ -0,0 +1,76 @@
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import log from '@converse/headless/log';
|
||||
import { __ } from 'i18n';
|
||||
import { _converse, api, converse } from '@converse/headless/core';
|
||||
|
||||
const { Strophe, sizzle, u } = converse.env;
|
||||
|
||||
|
||||
const 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfileModal;
|
26
src/plugins/omemo/overrides/user-details-modal.js
Normal file
26
src/plugins/omemo/overrides/user-details-modal.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { _converse } from '@converse/headless/core';
|
||||
|
||||
const 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));
|
||||
}
|
||||
}
|
||||
|
||||
export default UserDetailsModal;
|
283
src/plugins/omemo/store.js
Normal file
283
src/plugins/omemo/store.js
Normal file
@ -0,0 +1,283 @@
|
||||
/* global libsignal */
|
||||
import difference from 'lodash-es/difference';
|
||||
import invokeMap from 'lodash-es/invokeMap';
|
||||
import log from '@converse/headless/log';
|
||||
import range from 'lodash-es/range';
|
||||
import omit from 'lodash-es/omit';
|
||||
import { Model } from '@converse/skeletor/src/model.js';
|
||||
import { generateDeviceID } from './utils.js';
|
||||
import { _converse, api, converse } from '@converse/headless/core';
|
||||
|
||||
const { Strophe, $build, u } = converse.env;
|
||||
|
||||
|
||||
const 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;
|
||||
}
|
||||
});
|
||||
|
||||
export default OMEMOStore;
|
@ -1,7 +1,7 @@
|
||||
/* global libsignal */
|
||||
import difference from 'lodash-es/difference';
|
||||
import log from '@converse/headless/log';
|
||||
import { __ } from '../i18n';
|
||||
import { __ } from 'i18n';
|
||||
import { _converse, converse, api } from '@converse/headless/core';
|
||||
import { html } from 'lit-html';
|
||||
|
||||
@ -13,7 +13,7 @@ const KEY_ALGO = {
|
||||
'length': 128
|
||||
};
|
||||
|
||||
const omemo = (converse.env.omemo = {
|
||||
export const omemo = {
|
||||
async encryptMessage (plaintext) {
|
||||
// The client MUST use fresh, randomly generated key/IV pairs
|
||||
// with AES-128 in Galois/Counter Mode (GCM).
|
||||
@ -57,7 +57,7 @@ const omemo = (converse.env.omemo = {
|
||||
};
|
||||
return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function parseEncryptedMessage (stanza, attrs) {
|
||||
if (attrs.is_encrypted && attrs.encrypted.key) {
|
||||
@ -234,10 +234,11 @@ export function addKeysToMessageStanza (stanza, dicts, iv) {
|
||||
return Promise.resolve(stanza);
|
||||
}
|
||||
|
||||
function parseBundle (bundle_el) {
|
||||
/* Given an XML element representing a user's OMEMO bundle, parse it
|
||||
* and return a map.
|
||||
*/
|
||||
/**
|
||||
* Given an XML element representing a user's OMEMO bundle, parse it
|
||||
* and return a map.
|
||||
*/
|
||||
export function parseBundle (bundle_el) {
|
||||
const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
|
||||
const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
|
||||
const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({
|
||||
|
Loading…
Reference in New Issue
Block a user