xmpp.chapril.org-conversejs/src/converse-omemo.js
2018-08-24 16:32:40 +02:00

1011 lines
48 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, _, f, 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;
const TAG_LENGTH = 128;
const KEY_ALGO = {
'name': "AES-GCM",
'length': 256
};
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) && !f.includes('converse-omemo', _converse.blacklisted_plugins);
},
dependencies: ["converse-chatview"],
overrides: {
ProfileModal: {
events: {
'change input.select-all': 'selectAll',
'submit .fingerprint-removal': 'removeSelectedFingerprints'
},
initialize () {
const { _converse } = this.__super__;
this.debouncedRender = _.debounce(this.render, 50);
this.devicelist = _converse.devicelists.get(_converse.bare_jid);
this.devicelist.devices.on('change:bundle', this.debouncedRender, this);
this.devicelist.devices.on('reset', this.debouncedRender, this);
this.devicelist.devices.on('remove', this.debouncedRender, this);
this.devicelist.devices.on('add', this.debouncedRender, this);
return this.__super__.initialize.apply(this, arguments);
},
beforeRender () {
const { _converse } = this.__super__,
device_id = _converse.omemo_store.get('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 checkboxes = ev.target.querySelectorAll('.fingerprint-removal-item input[type="checkbox"]:checked'),
device_ids = _.map(checkboxes, 'value');
this.devicelist.removeOwnDevices(device_ids)
.then(this.modal.hide)
.catch(err => {
const { _converse } = this.__super__,
{ __ } = _converse;
_converse.log(err, Strophe.LogLevel.ERROR);
_converse.api.alert.show(
Strophe.LogLevel.ERROR,
__('Error'), [__('Sorry, an error occurred while trying to remove the devices.')]
)
});
},
},
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);
this.devicelist.devices.on('remove', this.render, this);
this.devicelist.devices.on('add', this.render, this);
this.devicelist.devices.on('reset', 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__;
let devices;
return _converse.getDevicesForContact(this.get('jid'))
.then((their_devices) => {
const device_id = _converse.omemo_store.get('device_id'),
devicelist = _converse.devicelists.get(_converse.bare_jid),
own_devices = devicelist.devices.filter(device => device.get('id') !== device_id);
devices = _.concat(own_devices, their_devices.models);
return Promise.all(devices.map(device => device.getBundle()));
}).then(() => this.buildSessions(devices))
},
buildSession (device) {
const { _converse } = this.__super__,
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address),
prekey = device.getRandomPreKey();
return device.getBundle()
.then(bundle => {
return sessionBuilder.processPreKey({
'registrationId': parseInt(device.get('id'), 10),
'identityKey': u.base64ToArrayBuffer(bundle.identity_key),
'signedPreKey': {
'keyId': bundle.signed_prekey.id, // <Number>
'publicKey': u.base64ToArrayBuffer(bundle.signed_prekey.public_key),
'signature': u.base64ToArrayBuffer(bundle.signed_prekey.signature)
},
'preKey': {
'keyId': prekey.id, // <Number>
'publicKey': u.base64ToArrayBuffer(prekey.key),
}
});
});
},
getSession (device) {
const { _converse } = this.__super__,
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
return _converse.omemo_store.loadSession(address.toString()).then(session => {
if (session) {
return Promise.resolve();
} else {
return this.buildSession(device);
}
});
},
getKeyAndTag (string) {
return {
'key': string.slice(0, 43), // 256bit key
'tag': string.slice(43, string.length) // rest is tag
}
},
decryptMessage (obj) {
const { _converse } = this.__super__,
key_obj = {
"alg": "A256GCM",
"ext": true,
"k": obj.key,
"key_ops": ["encrypt","decrypt"],
"kty": "oct"
};
return crypto.subtle.importKey('jwk', key_obj, KEY_ALGO, true, ['encrypt','decrypt'])
.then((key_obj) => {
const algo = {
'name': "AES-GCM",
'iv': u.base64ToArrayBuffer(obj.iv),
'tagLength': TAG_LENGTH
}
return window.crypto.subtle.decrypt(algo, key_obj, u.base64ToArrayBuffer(obj.payload));
}).then(out => (new TextDecoder()).decode(out));
},
reportDecryptionError (e) {
const { _converse } = this.__super__,
{ __ } = _converse;
this.messages.create({
'message': __("Sorry, could not decrypt a received OMEMO message due to an error.") + `${e.name} ${e.message}`,
'type': 'error',
});
_converse.log(e, Strophe.LogLevel.ERROR);
},
decrypt (attrs) {
const { _converse } = this.__super__,
devicelist = _converse.devicelists.get(attrs.from),
device = devicelist.devices.get(attrs.encrypted.device_id),
address = new libsignal.SignalProtocolAddress(
attrs.from,
parseInt(attrs.encrypted.device_id, 10)
),
session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address),
libsignal_payload = JSON.parse(atob(attrs.encrypted.key));
if (attrs.encrypted.prekey === 'true') {
// If this is the case, a new session is built from this received element. The client
// SHOULD then republish their bundle information, replacing the used PreKey, such
// that it won't be used again by a different client. If the client already has a session
// with the sender's device, it MUST replace this session with the newly built session.
// The client MUST delete the private key belonging to the PreKey after use.
return session_cipher.decryptPreKeyWhisperMessage(libsignal_payload.body, 'binary')
.then(key_and_tag => {
const aes_data = this.getKeyAndTag(u.arrayBufferToString(key_and_tag));
return this.decryptMessage(_.extend(attrs.encrypted, {'key': aes_data.key, 'tag': aes_data.tag}));
}).then(plaintext => {
// TODO remove newly used key before republishing
_converse.omemo_store.publishBundle()
return _.extend(attrs, {'plaintext': plaintext});
}).catch((e) => {
this.reportDecryptionError(e);
return attrs;
});
} else {
return session_cipher.decryptWhisperMessage(libsignal_payload.body, 'binary')
.then(key_and_tag => {
const aes_data = this.getKeyAndTag(u.arrayBufferToString(key_and_tag));
return this.decryptMessage(_.extend(attrs.encrypted, {'key': aes_data.key, 'tag': aes_data.tag}));
}).then(plaintext => _.extend(attrs, {'plaintext': plaintext}))
.catch((e) => {
this.reportDecryptionError(e);
return attrs;
});
}
},
getEncryptionAttributesfromStanza (stanza, original_stanza, attrs) {
const { _converse } = this.__super__,
encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop(),
header = encrypted.querySelector('header'),
key = sizzle(`key[rid="${_converse.omemo_store.get('device_id')}"]`, encrypted).pop();
if (key) {
attrs['is_encrypted'] = true;
attrs['encrypted'] = {
'device_id': header.getAttribute('sid'),
'iv': header.querySelector('iv').textContent,
'key': key.textContent,
'payload': _.get(encrypted.querySelector('payload'), 'textContent', null),
'prekey': key.getAttribute('prekey')
}
return this.decrypt(attrs);
} else {
return Promise.resolve(attrs);
}
},
getMessageAttributesFromStanza (stanza, original_stanza) {
const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, original_stanza).pop();
const attrs = this.__super__.getMessageAttributesFromStanza.apply(this, arguments);
if (!encrypted) {
return attrs;
} else {
return this.getEncryptionAttributesfromStanza(stanza, original_stanza, attrs);
}
},
buildSessions (devices) {
return Promise.all(devices.map(device => this.getSession(device))).then(() => devices);
},
encryptMessage (plaintext) {
// The client MUST use fresh, randomly generated key/IV pairs
// with AES-128 in Galois/Counter Mode (GCM).
const iv = window.crypto.getRandomValues(new window.Uint8Array(16));
let key;
return window.crypto.subtle.generateKey(
KEY_ALGO,
true, // extractable
["encrypt", "decrypt"] // key usages
).then((result) => {
key = result;
const algo = {
'name': 'AES-GCM',
'iv': iv,
'tagLength': TAG_LENGTH
}
return window.crypto.subtle.encrypt(algo, key, new TextEncoder().encode(plaintext));
}).then(ciphertext => {
return window.crypto.subtle.exportKey("jwk", key)
.then(key_obj => {
const tag = u.arrayBufferToBase64(ciphertext.slice(ciphertext.byteLength - ((TAG_LENGTH + 7) >> 3)));
return Promise.resolve({
'key': key_obj.k,
'tag': tag,
'key_and_tag': key_obj.k + tag,
'payload': u.arrayBufferToBase64(ciphertext),
'iv': u.arrayBufferToBase64(iv)
});
});
});
},
encryptKey (plaintext, device) {
const { _converse } = this.__super__,
address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')),
session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
return session_cipher.encrypt(plaintext).then(payload => ({'payload': payload, 'device': device}));
},
addKeysToMessageStanza (stanza, dicts, iv) {
for (var i in dicts) {
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
const payload = dicts[i].payload,
device = dicts[i].device,
prekey = 3 == parseInt(payload.type, 10);
stanza.c('key', {'rid': device.get('id') }).t(btoa(JSON.stringify(dicts[i].payload)));
if (prekey) {
stanza.attrs({'prekey': prekey});
}
stanza.up();
if (i == dicts.length-1) {
stanza.c('iv').t(iv).up().up()
}
}
}
return Promise.resolve(stanza);
},
createOMEMOMessageStanza (message, devices) {
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");
if (!message.get('message')) {
throw new Error("No message body to encrypt!");
}
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()
// 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.
.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
.c('header', {'sid': _converse.omemo_store.get('device_id')});
return this.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)
.map(device => this.encryptKey(obj.key_and_tag, device));
return Promise.all(promises)
.then((dicts) => this.addKeysToMessageStanza(stanza, dicts, obj.iv))
.then((stanza) => stanza.c('payload').t(obj.payload))
});
},
sendMessage (attrs) {
const { _converse } = this.__super__,
{ __ } = _converse;
if (this.get('omemo_active') && attrs.message) {
attrs['is_encrypted'] = true;
const message = this.messages.create(attrs);
this.getBundlesAndBuildSessions()
.then(devices => this.createOMEMOMessageStanza(message, devices))
.then(stanza => this.sendMessageStanza(stanza))
.catch(e => {
this.messages.create({
'message': __("Sorry, could not send the message due to an error.") + ` ${e.message}`,
'type': 'error',
});
_converse.log(e, Strophe.LogLevel.ERROR);
});
} 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) {
let bundle;
return device.getBundle().then(b => {
bundle = b;
return 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
});
}
_converse.generateFingerprints = function (jid) {
return _converse.getDevicesForContact(jid)
.then(devices => Promise.all(devices.map(d => generateFingerprint(d))))
}
_converse.getDevicesForContact = function (jid) {
let devicelist;
return _converse.api.waitUntil('OMEMOInitialized')
.then(() => {
devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
return devicelist.fetchDevices();
}).then(() => devicelist.devices);
}
_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.toString();
}
_converse.OMEMOStore = Backbone.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) {
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.arrayBufferToBase64(identity_key) === trusted);
},
loadIdentityKey (identifier) {
if (_.isNil(identifier)) {
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 (_.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());
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);
}
},
loadPreKey (keyId) {
let res = this.get('25519KeypreKey'+keyId);
if (res) {
res = {
'privKey': u.base64ToArrayBuffer(res.privKey),
'pubKey': u.base64ToArrayBuffer(res.pubKey)
};
}
return Promise.resolve(res);
},
storePreKey (keyId, keyPair) {
this.save('25519KeypreKey'+keyId, {
'privKey': u.arrayBufferToBase64(keyPair.privKey),
'pubKey': u.arrayBufferToBase64(keyPair.pubKey)
});
return Promise.resolve();
},
removePreKey (keyId) {
return Promise.resolve(this.unset('25519KeypreKey'+keyId));
},
loadSignedPreKey (keyId) {
let res = this.get('25519KeysignedKey'+keyId);
if (res) {
res = {
'privKey': u.base64ToArrayBuffer(res.privKey),
'pubKey': u.base64ToArrayBuffer(res.pubKey)
};
}
return Promise.resolve(res);
},
storeSignedPreKey (keyId, keyPair) {
this.save('25519KeysignedKey'+keyId, {
'privKey': u.arrayBufferToBase64(keyPair.privKey),
'pubKey': u.arrayBufferToBase64(keyPair.pubKey)
});
return Promise.resolve();
},
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();
},
publishBundle () {
const signed_prekey = this.get('signed_prekey');
const stanza = $iq({
'from': _converse.bare_jid,
'type': 'set'
}).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
.c('publish', {'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`})
.c('item')
.c('bundle', {'xmlns': Strophe.NS.OMEMO})
.c('signedPreKeyPublic', {'signedPreKeyId': signed_prekey.keyId})
.t(signed_prekey.keyPair.pubKey).up()
.c('signedPreKeySignature').t(signed_prekey.signature).up()
.c('identityKey').t(this.get('identity_keypair').pubKey).up()
.c('prekeys');
_.forEach(
this.get('prekeys').slice(0, _converse.NUM_PREKEYS),
(prekey) => stanza.c('preKeyPublic', {'preKeyId': prekey.keyId}).t(prekey.keyPair.pubKey).up()
);
return _converse.api.sendIQ(stanza);
},
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 data = {
'device_id': generateDeviceID()
};
return libsignal.KeyHelper.generateIdentityKeyPair()
.then(identity_keypair => {
const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
data['identity_keypair'] = {
'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
'pubKey': identity_key
};
data['identity_key'] = identity_key;
return libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 1);
}).then(signed_prekey => {
_converse.omemo_store.storeSignedPreKey(signed_prekey.keyId, signed_prekey.keyPair);
data['signed_prekey'] = {
'keyId': signed_prekey.keyId,
'keyPair': {
'privKey': u.arrayBufferToBase64(signed_prekey.keyPair.privKey),
'pubKey': u.arrayBufferToBase64(signed_prekey.keyPair.pubKey)
},
'signature': u.arrayBufferToBase64(signed_prekey.signature)
}
return Promise.all(_.map(_.range(0, _converse.NUM_PREKEYS), id => libsignal.KeyHelper.generatePreKey(id)));
}).then(keys => {
_.forEach(keys, k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
const marshalled_keys = _.map(keys, k => {
return {
'keyId': k.keyId,
'keyPair': {
'pubKey': u.arrayBufferToBase64(k.keyPair.pubKey),
'privKey': u.arrayBufferToBase64(k.keyPair.privKey)
}
}
});
data['prekeys'] = marshalled_keys;
this.save(data)
// Save the bundle to the device
const devicelist = _converse.devicelists.get(_converse.bare_jid),
device = devicelist.devices.create({'id': data.device_id, 'jid': _converse.bare_jid});
device.save('bundle', data);
});
},
fetchSession () {
if (_.isUndefined(this._setup_promise)) {
this._setup_promise = new Promise((resolve, reject) => {
this.fetch({
'success': () => {
if (!_converse.omemo_store.get('device_id')) {
this.generateBundle().then(resolve).catch(resolve);
} else {
resolve();
}
},
'error': () => {
this.generateBundle().then(resolve).catch(resolve);
}
});
});
}
return this._setup_promise;
}
});
_converse.Device = Backbone.Model.extend({
defaults: {
'trusted': UNDECIDED
},
getRandomPreKey () {
// XXX: assumes that the bundle has already been fetched
const bundle = this.get('bundle');
return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
},
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')}`});
return _converse.api.sendIQ(stanza)
.then(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);
return bundle;
}).catch(iq => {
_converse.log(iq.outerHTML, Strophe.LogLevel.ERROR);
});
},
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'), this);
} 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();
const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
this.devices.browserStorage = new Backbone.BrowserStorage.session(id);
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(ids => this.publishCurrentDevice(ids))
.then(resolve)
.catch(resolve);
} else {
resolve();
}
}
});
});
}
return this._devices_promise;
},
publishCurrentDevice (device_ids) {
if (this.get('jid') !== _converse.bare_jid) {
// We only publish for ourselves.
return Promise.resolve();
}
return restoreOMEMOSession()
.then(() => {
const device_id = _converse.omemo_store.get('device_id'),
own_device = this.devices.findWhere({'id': device_id});
if (!_.includes(device_ids, device_id)) {
return this.publishDevices();
}
});
},
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});
return _converse.api.sendIQ(stanza)
.then(iq => {
const device_ids = _.map(sizzle(`list[xmlns="${Strophe.NS.OMEMO}"] device`, iq), dev => dev.getAttribute('id'));
_.forEach(device_ids, id => this.devices.create({'id': id, 'jid': this.get('jid')}));
return device_ids;
});
},
publishDevices () {
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})
this.devices.each(device => stanza.c('device', {'id': device.get('id')}).up());
return _converse.api.sendIQ(stanza);
},
removeOwnDevices (device_ids) {
if (this.get('jid') !== _converse.bare_jid) {
throw new Error("Cannot remove devices from someone else's device list");
}
_.forEach(device_ids, (device_id) => this.devices.get(device_id).destroy());
return this.publishDevices();
}
});
_converse.DeviceLists = Backbone.Collection.extend({
model: _converse.DeviceList,
});
_converse.omemo = {
}
function fetchDeviceLists () {
return new Promise((resolve, reject) => _converse.devicelists.fetch({
'success': resolve
}));
}
function fetchOwnDevices () {
return fetchDeviceLists().then(() => {
let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
if (_.isNil(own_devicelist)) {
own_devicelist = _converse.devicelists.create({'jid': _converse.bare_jid});
}
return own_devicelist.fetchDevices();
});
}
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, (id) => {
if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
// We don't remove the current device
return
}
devices.get(id).destroy();
});
_.forEach(device_ids, (device_id) => {
if (!devices.get(device_id)) {
devices.create({'id': device_id, 'jid': jid})
}
});
if (Strophe.getBareJidFromJid(jid) === _converse.bare_jid) {
// Make sure our own device is on the list (i.e. if it was
// removed, add it again.
_converse.devicelists.get(_converse.bare_jid).publishCurrentDevice(device_ids);
}
}
function registerPEPPushHandler () {
// Add a handler for devices pushed from other connected clients
_converse.connection.addHandler((message) => {
try {
if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
updateDevicesFromStanza(message);
updateBundleFromStanza(message);
}
} catch (e) {
_converse.log(e.message, Strophe.LogLevel.ERROR);
}
return true;
}, null, 'message', 'headline');
}
function restoreOMEMOSession () {
if (_.isUndefined(_converse.omemo_store)) {
const storage = _converse.config.get('storage'),
id = `converse.omemosession-${_converse.bare_jid}`;
_converse.omemo_store = new _converse.OMEMOStore({'id': id});
_converse.omemo_store.browserStorage = new Backbone.BrowserStorage[storage](id);
}
return _converse.omemo_store.fetchSession();
}
function initOMEMO() {
_converse.devicelists = new _converse.DeviceLists();
const storage = _converse.config.get('storage'),
id = `converse.devicelists-${_converse.bare_jid}`;
_converse.devicelists.browserStorage = new Backbone.BrowserStorage[storage](id);
fetchOwnDevices()
.then(() => restoreOMEMOSession())
.then(() => _converse.omemo_store.publishBundle())
.then(() => _converse.emit('OMEMOInitialized'))
.catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
}
_converse.api.listen.on('afterTearDown', () => {
_converse.devicelists.reset();
delete _converse.omemo_store;
});
_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.generateFingerprints(jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
_converse.api.listen.on('profileModalInitialized', (contact) => {
_converse.generateFingerprints(_converse.bare_jid).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
});
}
});
}));