/* 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 our 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 = await api.omemo.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 })); }, /** * Generates, stores and then returns pre-keys. * * Pre-keys are one half of a X3DH key exchange and are published as part * of the device bundle. * * For a new contact or device to establish an encrypted session, it needs * to use a pre-key, which it chooses randomly from the list of available * ones. */ async generatePreKeys () { const amount = _converse.NUM_PREKEYS; const { KeyHelper } = libsignal; const keys = await Promise.all( range(0, amount).map(id => KeyHelper.generatePreKey(id)) ); keys.forEach(k => this.storePreKey(k.keyId, k.keyPair)); return keys.map(k => ({ 'id': k.keyId, 'key': u.arrayBufferToBase64(k.keyPair.pubKey) })); }, /** * Generate the cryptographic data used by the X3DH key agreement protocol * in order to build a session with other devices. * * By generating a bundle, and publishing it via PubSub, we allow other * clients to download it and start asynchronous encrypted sessions with us, * even if we're offline at that time. */ 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 public/private Key pair. const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair(); const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey); // The Device ID is a randomly generated integer between 1 and 2^31 - 1. const device_id = await generateDeviceID(); 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); this.storeSignedPreKey(signed_prekey); const prekeys = await this.generatePreKeys(); const bundle = { identity_key, device_id, prekeys }; bundle['signed_prekey'] = { 'id': signed_prekey.keyId, 'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.pubKey), 'signature': u.arrayBufferToBase64(signed_prekey.signature) }; const devicelist = await api.omemo.devicelists.get(_converse.bare_jid); const device = await devicelist.devices.create( { 'id': bundle.device_id, 'jid': _converse.bare_jid }, { 'promise': true } ); device.save('bundle', bundle); }, fetchSession () { if (this._setup_promise === undefined) { this._setup_promise = new Promise((resolve, reject) => { this.fetch({ 'success': () => { if (!this.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;