xmpp.chapril.org-conversejs/src/converse-omemo.js
2018-07-25 11:33:16 +02:00

714 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Converse.js
// http://conversejs.org
//
// Copyright (c) 2013-2018, the Converse.js developers
// Licensed under the Mozilla Public License (MPLv2)
/* global libsignal, ArrayBuffer, parseInt */
(function (root, factory) {
define([
"converse-core",
"templates/toolbar_omemo.html"
], factory);
}(this, function (converse, tpl_toolbar_omemo) {
const { Backbone, Promise, Strophe, moment, sizzle, $iq, $msg, _, b64_sha1 } = converse.env;
const u = converse.env.utils;
Strophe.addNamespace('OMEMO', "eu.siacs.conversations.axolotl");
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;
const UNTRUSTED = -1;
function parseBundle (bundle_el) {
/* Given an XML element representing a user's OMEMO bundle, parse it
* and return a map.
*/
const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic'),
signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature'),
identity_key_el = bundle_el.querySelector('identityKey');
const prekeys = _.map(
sizzle(`prekeys > preKeyPublic`, bundle_el),
(el) => {
return {
'id': parseInt(el.getAttribute('preKeyId'), 10),
'key': el.textContent
}
});
return {
'identity_key': bundle_el.querySelector('identityKey').textContent,
'signed_prekey': {
'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
'public_key': signed_prekey_public_el.textContent,
'signature': signed_prekey_signature_el.textContent
},
'prekeys': prekeys
}
}
converse.plugins.add('converse-omemo', {
enabled (_converse) {
return !_.isNil(window.libsignal);
},
dependencies: ["converse-chatview"],
overrides: {
UserDetailsModal: {
events: {
'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
},
initialize () {
const { _converse } = this.__super__;
const jid = this.model.get('jid');
this.devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
this.devicelist.devices.on('change:bundle', this.render, this);
this.devicelist.devices.on('change:trusted', this.render, this);
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: {
getBundlesAndBuildSessions () {
const { _converse } = this.__super__;
return new Promise((resolve, reject) => {
_converse.getDevicesForContact(this.get('jid'))
.then((devices) => {
const promises = devices.map((device) => device.getBundle());
Promise.all(promises).then(() => {
this.buildSessions(devices)
.then(() => resolve(devices))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
},
buildSessions (devices) {
const { _converse } = this.__super__,
device_id = _converse.omemo_store.get('device_id');
return Promise.all(_.map(devices, (device) => {
const recipient_id = device['id'];
const address = new libsignal.SignalProtocolAddress(parseInt(recipient_id, 10), device_id);
const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
return sessionBuilder.processPreKey({
'registrationId': _converse.omemo_store.get('registration_id'),
'identityKey': _converse.omemo_store.get('identity_keypair'),
'signedPreKey': {
'keyId': '', // <Number>,
'publicKey': '', // <ArrayBuffer>,
'signature': '', // <ArrayBuffer>
},
'preKey': {
'keyId': '', // <Number>,
'publicKey': '', // <ArrayBuffer>
}
});
}));
},
encryptMessage (message) {
// TODO:
// const { _converse } = this.__super__;
// const plaintext = message.get('message');
// const address = new libsignal.SignalProtocolAddress(recipientId, deviceId);
// return new Promise((resolve, reject) => {
// var sessionCipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
// sessionCipher.encrypt(plaintext).then((ciphertext) => {});
// });
},
createOMEMOMessageStanza (message, bundles) {
const { _converse } = this.__super__, { __ } = _converse;
const body = __("This is an OMEMO encrypted message which your client doesnt seem to support. "+
"Find more information on https://conversations.im/omemo");
return new Promise((resolve, reject) => {
this.encryptMessage(message).then((payload) => {
const stanza = $msg({
'from': _converse.connection.jid,
'to': this.get('jid'),
'type': this.get('message_type'),
'id': message.get('msgid')
}).c('body').t(body).up()
.c('encrypted').t(payload)
.c('header').t(payload).up()
_.forEach(bundles, (bundle) => {
const prekey = bundle.prekeys[Math.random(bundle.prekeys.length)].textContent;
stanza('key', {'rid': bundle.identity_key}).t(prekey).up()
});
// TODO: set storage hint urn:xmpp:hints
resolve(stanza);
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
},
sendMessage (attrs) {
if (this.get('omemo_active')) {
const message = this.messages.create(attrs);
this.getBundlesAndBuildSessions()
.then((bundles) => this.createOMEMOMessageStanza(message, bundles))
.then((stanza) => this.sendMessageStanza(stanza));
} else {
return this.__super__.sendMessage.apply(this, arguments);
}
},
},
ChatBoxView: {
events: {
'click .toggle-omemo': 'toggleOMEMO'
},
renderOMEMOToolbarButton () {
const { _converse } = this.__super__,
{ __ } = _converse;
_converse.contactHasOMEMOSupport(this.model.get('jid')).then((support) => {
if (support) {
const icon = this.el.querySelector('.toggle-omemo'),
html = tpl_toolbar_omemo(_.extend(this.model.toJSON(), {'__': __}));
if (icon) {
icon.outerHTML = html;
} else {
this.el.querySelector('.chat-toolbar').insertAdjacentHTML('beforeend', html);
}
}
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
},
toggleOMEMO (ev) {
ev.preventDefault();
this.model.save({'omemo_active': !this.model.get('omemo_active')});
this.renderOMEMOToolbarButton();
}
}
},
initialize () {
/* The initialize function gets called as soon as the plugin is
* loaded by Converse.js's plugin machinery.
*/
const { _converse } = this;
_converse.api.promises.add(['OMEMOInitialized']);
_converse.NUM_PREKEYS = 100; // Set here so that tests can override
function generateFingerprint (device) {
return new Promise((resolve, reject) => {
device.getBundle().then((bundle) => {
// TODO: only generate fingerprints when necessary
crypto.subtle.digest('SHA-1', u.base64ToArrayBuffer(bundle['identity_key']))
.then((fp) => {
bundle['fingerprint'] = u.arrayBufferToHex(fp);
device.save('bundle', bundle);
device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
resolve();
}).catch(reject);
});
});
}
_converse.getFingerprintsForContact = function (jid) {
return new Promise((resolve, reject) => {
_converse.getDevicesForContact(jid)
.then((devices) => Promise.all(devices.map(d => generateFingerprint(d))).then(resolve).catch(reject));
});
}
_converse.getDevicesForContact = function (jid) {
return new Promise((resolve, reject) => {
_converse.api.waitUntil('OMEMOInitialized').then(() => {
let devicelist = _converse.devicelists.get(jid);
if (_.isNil(devicelist)) {
devicelist = _converse.devicelists.create({'jid': jid});
}
devicelist.fetchDevices().then(() => resolve(devicelist.devices));
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
}
_converse.contactHasOMEMOSupport = function (jid) {
/* Checks whether the contact advertises any OMEMO-compatible devices. */
return new Promise((resolve, reject) => {
_converse.getDevicesForContact(jid)
.then((devices) => resolve(devices.length > 0))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
}
function generateDeviceID () {
/* Generates a device ID, making sure that it's unique */
const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
let device_id = libsignal.KeyHelper.generateRegistrationId();
let i = 0;
while (_.includes(existing_ids, device_id)) {
device_id = libsignal.KeyHelper.generateRegistrationId();
i++;
if (i == 10) {
throw new Error("Unable to generate a unique device ID");
}
}
return device_id;
}
function 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.
*/
return new Promise((resolve, reject) => {
libsignal.KeyHelper.generateIdentityKeyPair().then((identity_keypair) => {
const data = {
'device_id': generateDeviceID(),
'identity_keypair': identity_keypair,
'prekeys': {}
};
const signed_prekey_id = '0';
libsignal.KeyHelper.generateSignedPreKey(identity_keypair, signed_prekey_id)
.then((signed_prekey) => {
data['signed_prekey'] = signed_prekey;
const key_promises = _.map(_.range(0, _converse.NUM_PREKEYS), (id) => libsignal.KeyHelper.generatePreKey(id));
Promise.all(key_promises).then((keys) => {
data['prekeys'] = keys;
resolve(data)
});
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}
_converse.OMEMOStore = Backbone.Model.extend({
Direction: {
SENDING: 1,
RECEIVING: 2,
},
getIdentityKeyPair () {
return Promise.resolve(this.get('identity_keypair'));
},
getLocalRegistrationId () {
return Promise.resolve(this.get('device_id'));
},
isTrustedIdentity (identifier, identity_key, direction) {
if (_.isNil(identifier)) {
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.arrayBufferToString(identity_key) === u.arrayBufferToString(trusted));
},
loadIdentityKey (identifier) {
if (_.isNil(identifier)) {
throw new Error("Can't load identity_key for invalid identifier");
}
return Promise.resolve(this.get('identity_key'+identifier));
},
saveIdentity (identifier, identity_key) {
if (_.isNil(identifier)) {
throw new Error("Can't save identity_key for invalid identifier");
}
const address = new libsignal.SignalProtocolAddress.fromString(identifier),
existing = this.get('identity_key'+address.getName());
this.save('identity_key'+address.getName(), identity_key)
if (existing && u.arrayBufferToString(identity_key) !== u.arrayBufferToString(existing)) {
return Promise.resolve(true);
} else {
return Promise.resolve(false);
}
},
loadPreKey (keyId) {
let res = this.get('25519KeypreKey'+keyId);
if (_.isUndefined(res)) {
res = {'pubKey': res.pubKey, 'privKey': res.privKey};
}
return Promise.resolve(res);
},
storePreKey (keyId, keyPair) {
return Promise.resolve(this.save('25519KeypreKey'+keyId, keyPair));
},
removePreKey (keyId) {
return Promise.resolve(this.unset('25519KeypreKey'+keyId));
},
loadSignedPreKey (keyId) {
let res = this.get('25519KeysignedKey'+keyId);
if (res !== undefined) {
res = {'pubKey': res.pubKey, 'privKey': res.privKey};
}
return Promise.resolve(res);
},
storeSignedPreKey (keyId, keyPair) {
return Promise.resolve(this.save('25519KeysignedKey'+keyId, keyPair));
},
removeSignedPreKey (keyId) {
return Promise.resolve(this.unset('25519KeysignedKey'+keyId));
},
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 = _.filter(_.keys(this.attributes), (key) => {
if (key.startsWith('session'+identifier)) {
return key;
}
});
const attrs = {};
_.forEach(keys, (key) => {attrs[key] = undefined});
this.save(attrs);
return Promise.resolve();
},
fetchSession () {
if (_.isUndefined(this._setup_promise)) {
this._setup_promise = new Promise((resolve, reject) => {
this.fetch({
'success': () => {
if (!_converse.omemo_store.get('device_id')) {
generateBundle()
.then((data) => {
// TODO: should storeSession be used here?
_converse.omemo_store.save(data);
resolve();
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
} else {
resolve();
}
}
});
});
}
return this._setup_promise;
}
});
_converse.Device = Backbone.Model.extend({
defaults: {
'active': true,
'trusted': UNDECIDED
},
fetchBundleFromServer () {
return new Promise((resolve, reject) => {
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')}`});
_converse.connection.sendIQ(
stanza,
(iq) => {
const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(),
bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(),
bundle = parseBundle(bundle_el);
this.save('bundle', bundle);
resolve(bundle);
},
reject,
_converse.IQ_TIMEOUT
);
});
},
getBundle () {
/* Fetch and save the bundle information associated with
* this device, if the information is not at hand already.
*/
if (this.get('bundle')) {
return Promise.resolve(this.get('bundle').toJSON());
} else {
return this.fetchBundleFromServer();
}
}
});
_converse.Devices = Backbone.Collection.extend({
model: _converse.Device,
});
_converse.DeviceList = Backbone.Model.extend({
idAttribute: 'jid',
initialize () {
this.devices = new _converse.Devices();
this.devices.browserStorage = new Backbone.BrowserStorage.session(
b64_sha1(`converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`)
);
this.fetchDevices();
},
fetchDevices () {
if (_.isUndefined(this._devices_promise)) {
this._devices_promise = new Promise((resolve, reject) => {
this.devices.fetch({
'success': (collection) => {
if (collection.length === 0) {
this.fetchDevicesFromServer().then(resolve).catch(reject);
} else {
resolve();
}
}
});
});
}
return this._devices_promise;
},
fetchDevicesFromServer () {
return new Promise((resolve, reject) => {
const stanza = $iq({
'type': 'get',
'from': _converse.bare_jid,
'to': this.get('jid')
}).c('query', {
'xmlns': Strophe.NS.DISCO_ITEMS,
'node': Strophe.NS.OMEMO_DEVICELIST
});
_converse.connection.sendIQ(
stanza,
(iq) => {
_.forEach(
iq.querySelectorAll('device'),
(dev) => this.devices.create({'id': dev.getAttribute('id'), 'jid': this.get('jid')})
);
resolve();
},
reject,
_converse.IQ_TIMEOUT);
});
},
addDeviceToList (device_id) {
/* Add this device to our list of devices stored on the
* server.
* https://xmpp.org/extensions/xep-0384.html#usecases-announcing
*/
this.devices.create({'id': device_id, 'jid': this.get('jid')});
return new Promise((resolve, reject) => {
const stanza = $iq({
'from': _converse.bare_jid,
'type': 'set'
}).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
.c('publish', {'node': Strophe.NS.OMEMO_DEVICELIST})
.c('item')
.c('list', {'xmlns': Strophe.NS.OMEMO}).up()
_.each(this.devices.where({'active': true}), (device) => {
stanza.c('device', {'id': device.get('id')}).up();
});
_converse.connection.sendIQ(stanza, resolve, reject, _converse.IQ_TIMEOUT);
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}
});
_converse.DeviceLists = Backbone.Collection.extend({
model: _converse.DeviceList,
});
function publishBundle () {
const store = _converse.omemo_store,
signed_prekey = store.get('signed_prekey');
return new Promise((resolve, reject) => {
const stanza = $iq({
'from': _converse.bare_jid,
'type': 'set'
}).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
.c('publish', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${store.get('device_id')}`})
.c('item')
.c('bundle', {'xmlns': Strophe.NS.OMEMO})
.c('signedPreKeyPublic', {'signedPreKeyId': signed_prekey.keyId})
.t(u.arrayBufferToBase64(signed_prekey.keyPair.pubKey)).up()
.c('signedPreKeySignature')
.t(u.arrayBufferToBase64(signed_prekey.signature)).up()
.c('identityKey')
.t(u.arrayBufferToBase64(store.get('identity_keypair').pubKey)).up()
.c('prekeys');
_.forEach(
store.get('prekeys').slice(0, _converse.NUM_PREKEYS),
(prekey) => {
stanza.c('preKeyPublic', {'preKeyId': prekey.keyId})
.t(u.arrayBufferToBase64(prekey.keyPair.pubKey)).up();
});
_converse.connection.sendIQ(stanza, resolve, reject, _converse.IQ_TIMEOUT);
});
}
function fetchDeviceLists () {
return new Promise((resolve, reject) => _converse.devicelists.fetch({'success': resolve}));
}
function fetchOwnDevices () {
return new Promise((resolve, reject) => {
fetchDeviceLists().then(() => {
let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
if (_.isNil(own_devicelist)) {
own_devicelist = _converse.devicelists.create({'jid': _converse.bare_jid});
}
own_devicelist.fetchDevices().then(resolve).catch(reject);
});
});
}
function updateOwnDeviceList () {
/* If our own device is not on the list, add it.
* Also, deduplicate devices if necessary.
*/
const devicelist = _converse.devicelists.get(_converse.bare_jid),
device_id = _converse.omemo_store.get('device_id'),
own_device = devicelist.devices.findWhere({'id': device_id});
if (!own_device) {
return devicelist.addDeviceToList(device_id);
} else if (!own_device.get('active')) {
own_device.set('active', true, {'silent': true});
return devicelist.addDeviceToList(device_id);
} else {
return Promise.resolve();
}
}
function updateBundleFromStanza (stanza) {
const items_el = sizzle(`items`, stanza).pop();
if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
return;
}
const device_id = items_el.getAttribute('node').split(':')[1],
jid = stanza.getAttribute('from'),
bundle_el = sizzle(`item > bundle`, items_el).pop(),
devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}),
device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
device.save({'bundle': parseBundle(bundle_el)});
}
function updateDevicesFromStanza (stanza) {
const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
if (!items_el) {
return;
}
const device_ids = _.map(
sizzle(`item list[xmlns="${Strophe.NS.OMEMO}"] device`, items_el),
(device) => device.getAttribute('id')
);
const jid = stanza.getAttribute('from'),
devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid}),
devices = devicelist.devices,
removed_ids = _.difference(devices.pluck('id'), device_ids);
_.forEach(removed_ids, (removed_id) => devices.get(removed_id).set('active', false));
_.forEach(device_ids, (device_id) => {
const dev = devices.get(device_id);
if (dev) {
dev.save({'active': true});
} else {
devices.create({'id': device_id, 'jid': jid})
}
});
// Make sure our own device is on the list (i.e. if it was
// removed, add it again.
updateOwnDeviceList();
}
function registerPEPPushHandler () {
// Add a handler for devices pushed from other connected clients
_converse.connection.addHandler((message) => {
if (message.querySelector('event[xmlns="'+Strophe.NS.PUBSUB+'#event"]')) {
updateDevicesFromStanza(message);
updateBundleFromStanza(message);
}
return true;
}, null, 'message', 'headline');
}
function restoreOMEMOSession () {
if (_.isUndefined(_converse.omemo_store)) {
_converse.omemo_store = new _converse.OMEMOStore();
_converse.omemo_store.browserStorage = new Backbone.BrowserStorage[_converse.storage](
b64_sha1(`converse.omemosession-${_converse.bare_jid}`)
);
}
return _converse.omemo_store.fetchSession();
}
function initOMEMO() {
_converse.devicelists = new _converse.DeviceLists();
_converse.devicelists.browserStorage = new Backbone.BrowserStorage[_converse.storage](
b64_sha1(`converse.devicelists-${_converse.bare_jid}`)
);
fetchOwnDevices()
.then(() => restoreOMEMOSession())
.then(() => updateOwnDeviceList())
.then(() => publishBundle())
.then(() => _converse.emit('OMEMOInitialized'))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}
_converse.api.listen.on('afterTearDown', () => _converse.devices.reset());
_converse.api.listen.on('connected', registerPEPPushHandler);
_converse.api.listen.on('renderToolbar', (view) => view.renderOMEMOToolbarButton());
_converse.api.listen.on('statusInitialized', initOMEMO);
_converse.api.listen.on('addClientFeatures',
() => _converse.api.disco.own.features.add(Strophe.NS.OMEMO_DEVICELIST+"notify"));
_converse.api.listen.on('userDetailsModalInitialized', (contact) => {
const jid = contact.get('jid');
_converse.getFingerprintsForContact(jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
}
});
}));