2019-07-11 10:48:52 +02:00
|
|
|
|
/**
|
|
|
|
|
* @module converse-omemo
|
2020-01-26 16:21:20 +01:00
|
|
|
|
* @copyright The Converse.js contributors
|
2019-09-19 16:54:55 +02:00
|
|
|
|
* @license Mozilla Public License (MPLv2)
|
2019-07-11 10:48:52 +02:00
|
|
|
|
*/
|
2020-02-20 19:18:06 +01:00
|
|
|
|
/* global libsignal */
|
2020-01-26 16:21:20 +01:00
|
|
|
|
|
2019-08-08 16:20:55 +02:00
|
|
|
|
import "converse-profile";
|
2020-05-18 10:54:37 +02:00
|
|
|
|
import log from "@converse/headless/log";
|
2020-06-08 16:08:50 +02:00
|
|
|
|
import { Collection } from "@converse/skeletor/src/collection";
|
|
|
|
|
import { Model } from '@converse/skeletor/src/model.js';
|
2020-09-10 07:33:00 +02:00
|
|
|
|
import { __ } from './i18n';
|
2020-05-18 10:54:37 +02:00
|
|
|
|
import { _converse, api, converse } from "@converse/headless/converse-core";
|
2020-06-08 16:08:50 +02:00
|
|
|
|
import { concat, debounce, difference, invokeMap, range, omit } from "lodash-es";
|
2020-06-08 12:12:49 +02:00
|
|
|
|
import { html } from 'lit-html';
|
2018-10-23 03:41:38 +02:00
|
|
|
|
|
2020-03-06 15:19:48 +01:00
|
|
|
|
const { Strophe, sizzle, $build, $iq, $msg } = converse.env;
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const u = converse.env.utils;
|
|
|
|
|
|
|
|
|
|
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;
|
2019-10-09 16:01:38 +02:00
|
|
|
|
const TRUSTED = 1; // eslint-disable-line no-unused-vars
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const UNTRUSTED = -1;
|
|
|
|
|
const TAG_LENGTH = 128;
|
|
|
|
|
const KEY_ALGO = {
|
|
|
|
|
'name': "AES-GCM",
|
|
|
|
|
'length': 128
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2018-12-19 17:18:36 +01:00
|
|
|
|
class IQError extends Error {
|
|
|
|
|
constructor (message, iq) {
|
|
|
|
|
super(message, iq);
|
|
|
|
|
this.name = 'IQError';
|
|
|
|
|
this.iq = iq;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-11-18 10:49:31 +01:00
|
|
|
|
|
|
|
|
|
function parseEncryptedMessage (stanza, attrs) {
|
|
|
|
|
if (attrs.is_encrypted && attrs.encrypted.key) {
|
|
|
|
|
// https://xmpp.org/extensions/xep-0384.html#usecases-receiving
|
|
|
|
|
if (attrs.encrypted.prekey === true) {
|
|
|
|
|
return decryptPrekeyWhisperMessage(attrs);
|
|
|
|
|
} else {
|
|
|
|
|
return decryptWhisperMessage(attrs);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return attrs;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function onChatBoxesInitialized () {
|
|
|
|
|
_converse.chatboxes.on('add', chatbox => {
|
|
|
|
|
checkOMEMOSupported(chatbox);
|
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o));
|
|
|
|
|
chatbox.features.on('change', () => checkOMEMOSupported(chatbox));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function onChatInitialized (view) {
|
|
|
|
|
view.listenTo(view.model.messages, 'add', (message) => {
|
|
|
|
|
if (message.get('is_encrypted') && !message.get('is_error')) {
|
|
|
|
|
view.model.save('omemo_supported', true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
view.listenTo(view.model, 'change:omemo_supported', () => {
|
|
|
|
|
if (!view.model.get('omemo_supported') && view.model.get('omemo_active')) {
|
|
|
|
|
view.model.set('omemo_active', false);
|
|
|
|
|
} else {
|
|
|
|
|
// Manually trigger an update, setting omemo_active to
|
|
|
|
|
// false above will automatically trigger one.
|
|
|
|
|
view.el.querySelector('converse-chat-toolbar')?.requestUpdate();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
view.listenTo(view.model, 'change:omemo_active', () => {
|
|
|
|
|
view.el.querySelector('converse-chat-toolbar').requestUpdate();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2020-09-06 12:30:30 +02:00
|
|
|
|
const omemo = converse.env.omemo = {
|
|
|
|
|
|
|
|
|
|
async encryptMessage (plaintext) {
|
|
|
|
|
// The client MUST use fresh, randomly generated key/IV pairs
|
|
|
|
|
// with AES-128 in Galois/Counter Mode (GCM).
|
|
|
|
|
|
|
|
|
|
// For GCM a 12 byte IV is strongly suggested as other IV lengths
|
|
|
|
|
// will require additional calculations. In principle any IV size
|
|
|
|
|
// can be used as long as the IV doesn't ever repeat. NIST however
|
|
|
|
|
// suggests that only an IV size of 12 bytes needs to be supported
|
|
|
|
|
// by implementations.
|
|
|
|
|
//
|
|
|
|
|
// https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
|
|
|
|
|
const iv = crypto.getRandomValues(new window.Uint8Array(12)),
|
|
|
|
|
key = await crypto.subtle.generateKey(KEY_ALGO, true, ["encrypt", "decrypt"]),
|
|
|
|
|
algo = {
|
|
|
|
|
'name': 'AES-GCM',
|
|
|
|
|
'iv': iv,
|
|
|
|
|
'tagLength': TAG_LENGTH
|
|
|
|
|
},
|
|
|
|
|
encrypted = await crypto.subtle.encrypt(algo, key, u.stringToArrayBuffer(plaintext)),
|
|
|
|
|
length = encrypted.byteLength - ((128 + 7) >> 3),
|
|
|
|
|
ciphertext = encrypted.slice(0, length),
|
|
|
|
|
tag = encrypted.slice(length),
|
|
|
|
|
exported_key = await crypto.subtle.exportKey("raw", key);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
'key': exported_key,
|
|
|
|
|
'tag': tag,
|
|
|
|
|
'key_and_tag': u.appendArrayBuffer(exported_key, tag),
|
|
|
|
|
'payload': u.arrayBufferToBase64(ciphertext),
|
|
|
|
|
'iv': u.arrayBufferToBase64(iv)
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async decryptMessage (obj) {
|
|
|
|
|
const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt','decrypt']);
|
|
|
|
|
const cipher = u.appendArrayBuffer(u.base64ToArrayBuffer(obj.payload), obj.tag);
|
|
|
|
|
const algo = {
|
|
|
|
|
'name': "AES-GCM",
|
|
|
|
|
'iv': u.base64ToArrayBuffer(obj.iv),
|
|
|
|
|
'tagLength': TAG_LENGTH
|
|
|
|
|
};
|
|
|
|
|
return u.arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function getSessionCipher (jid, id) {
|
|
|
|
|
const address = new libsignal.SignalProtocolAddress(jid, id);
|
|
|
|
|
return new window.libsignal.SessionCipher(_converse.omemo_store, address);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
|
|
|
|
|
const encrypted = attrs.encrypted;
|
|
|
|
|
const devicelist = _converse.devicelists.getDeviceList(attrs.from);
|
|
|
|
|
await devicelist._devices_promise;
|
|
|
|
|
|
|
|
|
|
let device = devicelist.get(encrypted.device_id);
|
|
|
|
|
if (!device) {
|
|
|
|
|
device = await devicelist.devices.create({'id': encrypted.device_id, 'jid': attrs.from}, {'promise': true});
|
|
|
|
|
}
|
|
|
|
|
if (encrypted.payload) {
|
|
|
|
|
const key = key_and_tag.slice(0, 16);
|
|
|
|
|
const tag = key_and_tag.slice(16);
|
|
|
|
|
const result = await omemo.decryptMessage(Object.assign(encrypted, {'key': key, 'tag': tag}));
|
|
|
|
|
device.save('active', true);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDecryptionErrorAttributes (e) {
|
|
|
|
|
if (api.settings.get("loglevel") === 'debug') {
|
|
|
|
|
return {
|
|
|
|
|
'error_text': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
|
|
|
|
|
'error_type': 'Decryption',
|
|
|
|
|
'is_ephemeral': true,
|
|
|
|
|
'is_error': true,
|
|
|
|
|
'type': 'error',
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decryptPrekeyWhisperMessage (attrs) {
|
|
|
|
|
const session_cipher = getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
|
|
|
|
|
const key = u.base64ToArrayBuffer(attrs.encrypted.key);
|
|
|
|
|
let key_and_tag;
|
|
|
|
|
try {
|
|
|
|
|
key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// TODO from the XEP:
|
|
|
|
|
// There are various reasons why decryption of an
|
|
|
|
|
// OMEMOKeyExchange or an OMEMOAuthenticatedMessage
|
|
|
|
|
// could fail. One reason is if the message was
|
|
|
|
|
// received twice and already decrypted once, in this
|
|
|
|
|
// case the client MUST ignore the decryption failure
|
|
|
|
|
// and not show any warnings/errors. In all other cases
|
|
|
|
|
// of decryption failure, clients SHOULD respond by
|
|
|
|
|
// forcibly doing a new key exchange and sending a new
|
|
|
|
|
// OMEMOKeyExchange with a potentially empty SCE
|
|
|
|
|
// payload. By building a new session with the original
|
|
|
|
|
// sender this way, the invalid session of the original
|
|
|
|
|
// sender will get overwritten with this newly created,
|
|
|
|
|
// valid session.
|
|
|
|
|
log.error(`${e.name} ${e.message}`);
|
|
|
|
|
return Object.assign(attrs, getDecryptionErrorAttributes(e));
|
|
|
|
|
}
|
|
|
|
|
// TODO from the XEP:
|
|
|
|
|
// When a client receives the first message for a given
|
|
|
|
|
// ratchet key with a counter of 53 or higher, it MUST send
|
|
|
|
|
// a heartbeat message. Heartbeat messages are normal OMEMO
|
|
|
|
|
// encrypted messages where the SCE payload does not include
|
|
|
|
|
// any elements. These heartbeat messages cause the ratchet
|
|
|
|
|
// to forward, thus consequent messages will have the
|
|
|
|
|
// counter restarted from 0.
|
|
|
|
|
try {
|
|
|
|
|
const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
|
|
|
|
|
await _converse.omemo_store.generateMissingPreKeys();
|
|
|
|
|
await _converse.omemo_store.publishBundle();
|
|
|
|
|
if (plaintext) {
|
|
|
|
|
return Object.assign(attrs, {'plaintext': plaintext});
|
|
|
|
|
} else {
|
|
|
|
|
return Object.assign(attrs, {'is_only_key': true});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`${e.name} ${e.message}`);
|
|
|
|
|
return Object.assign(attrs, getDecryptionErrorAttributes(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decryptWhisperMessage (attrs) {
|
2020-09-17 17:05:59 +02:00
|
|
|
|
const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from;
|
|
|
|
|
if (!from_jid) {
|
|
|
|
|
Object.assign(attrs, {
|
|
|
|
|
'error_text': __("Sorry, could not decrypt a received OMEMO because we don't have the JID for that user."),
|
|
|
|
|
'error_type': 'Decryption',
|
|
|
|
|
'is_ephemeral': false,
|
|
|
|
|
'is_error': true,
|
|
|
|
|
'type': 'error',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
|
2020-09-06 12:30:30 +02:00
|
|
|
|
const key = u.base64ToArrayBuffer(attrs.encrypted.key);
|
|
|
|
|
try {
|
|
|
|
|
const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary')
|
|
|
|
|
const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
|
|
|
|
|
return Object.assign(attrs, {'plaintext': plaintext});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`${e.name} ${e.message}`);
|
|
|
|
|
return Object.assign(attrs, getDecryptionErrorAttributes(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-12-19 17:18:36 +01:00
|
|
|
|
|
2020-06-08 12:12:49 +02:00
|
|
|
|
function addKeysToMessageStanza (stanza, dicts, iv) {
|
|
|
|
|
for (const i in dicts) {
|
|
|
|
|
if (Object.prototype.hasOwnProperty.call(dicts, i)) {
|
2020-09-16 13:28:59 +02:00
|
|
|
|
const payload = dicts[i].payload;
|
|
|
|
|
const device = dicts[i].device;
|
|
|
|
|
const prekey = 3 == parseInt(payload.type, 10);
|
2020-06-08 12:12:49 +02:00
|
|
|
|
|
|
|
|
|
stanza.c('key', {'rid': device.get('id') }).t(btoa(payload.body));
|
|
|
|
|
if (prekey) {
|
|
|
|
|
stanza.attrs({'prekey': prekey});
|
|
|
|
|
}
|
|
|
|
|
stanza.up();
|
|
|
|
|
if (i == dicts.length-1) {
|
|
|
|
|
stanza.c('iv').t(iv).up().up()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve(stanza);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
function parseBundle (bundle_el) {
|
|
|
|
|
/* Given an XML element representing a user's OMEMO bundle, parse it
|
|
|
|
|
* and return a map.
|
|
|
|
|
*/
|
2019-10-09 16:01:38 +02:00
|
|
|
|
const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
|
|
|
|
|
const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
|
2019-09-10 15:47:30 +02:00
|
|
|
|
const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el)
|
|
|
|
|
.map(el => ({
|
|
|
|
|
'id': parseInt(el.getAttribute('preKeyId'), 10),
|
|
|
|
|
'key': el.textContent
|
|
|
|
|
}));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return {
|
|
|
|
|
'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
|
|
|
|
|
'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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-08 12:12:49 +02:00
|
|
|
|
async function generateFingerprint (device) {
|
|
|
|
|
if (device.get('bundle')?.fingerprint) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const bundle = await device.getBundle();
|
|
|
|
|
bundle['fingerprint'] = u.arrayBufferToHex(u.base64ToArrayBuffer(bundle['identity_key']));
|
|
|
|
|
device.save('bundle', bundle);
|
|
|
|
|
device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function getDevicesForContact (jid) {
|
|
|
|
|
await api.waitUntil('OMEMOInitialized');
|
|
|
|
|
const devicelist = _converse.devicelists.get(jid) || _converse.devicelists.create({'jid': jid});
|
|
|
|
|
await devicelist.fetchDevices();
|
|
|
|
|
return devicelist.devices;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2020-09-06 12:30:30 +02:00
|
|
|
|
|
|
|
|
|
// Before publishing a freshly generated device id for the first time,
|
|
|
|
|
// a device MUST check whether that device id already exists, and if so, generate a new one.
|
2020-06-08 12:12:49 +02:00
|
|
|
|
let i = 0;
|
|
|
|
|
while (existing_ids.includes(device_id)) {
|
|
|
|
|
device_id = libsignal.KeyHelper.generateRegistrationId();
|
|
|
|
|
i++;
|
2020-09-06 12:30:30 +02:00
|
|
|
|
if (i === 10) {
|
2020-06-08 12:12:49 +02:00
|
|
|
|
throw new Error("Unable to generate a unique device ID");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return device_id.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function buildSession (device) {
|
2020-09-06 12:30:30 +02:00
|
|
|
|
// TODO: check device-get('jid') versus the 'from' attribute which is used
|
|
|
|
|
// to build a session when receiving an encrypted message in a MUC.
|
|
|
|
|
// https://github.com/conversejs/converse.js/issues/1481#issuecomment-509183431
|
|
|
|
|
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
|
|
|
|
|
const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
|
|
|
|
|
const prekey = device.getRandomPreKey();
|
|
|
|
|
const bundle = await device.getBundle();
|
2020-06-08 12:12:49 +02:00
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getSession (device) {
|
2020-09-18 13:27:51 +02:00
|
|
|
|
if (!device.get('bundle')) {
|
|
|
|
|
log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2020-06-08 12:12:49 +02:00
|
|
|
|
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
|
|
|
|
|
const session = await _converse.omemo_store.loadSession(address.toString());
|
|
|
|
|
if (session) {
|
2020-09-06 12:30:30 +02:00
|
|
|
|
return session;
|
2020-06-08 12:12:49 +02:00
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
const session = await buildSession(device);
|
|
|
|
|
return session;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
|
|
|
|
|
log.error(e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateBundleFromStanza (stanza) {
|
|
|
|
|
const items_el = sizzle(`items`, stanza).pop();
|
|
|
|
|
if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2020-09-06 12:30:30 +02:00
|
|
|
|
const device_id = items_el.getAttribute('node').split(':')[1];
|
|
|
|
|
const jid = stanza.getAttribute('from');
|
|
|
|
|
const bundle_el = sizzle(`item > bundle`, items_el).pop();
|
|
|
|
|
const devicelist = _converse.devicelists.getDeviceList(jid);
|
|
|
|
|
const device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
|
2020-06-08 12:12:49 +02:00
|
|
|
|
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_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
|
|
|
|
|
const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
|
|
|
|
|
const jid = stanza.getAttribute('from');
|
|
|
|
|
const devicelist = _converse.devicelists.getDeviceList(jid);
|
|
|
|
|
const devices = devicelist.devices;
|
|
|
|
|
const removed_ids = difference(devices.pluck('id'), device_ids);
|
|
|
|
|
|
|
|
|
|
removed_ids.forEach(id => {
|
|
|
|
|
if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
|
|
|
|
|
return // We don't set the current device as inactive
|
|
|
|
|
}
|
|
|
|
|
devices.get(id).save('active', false);
|
|
|
|
|
});
|
|
|
|
|
device_ids.forEach(device_id => {
|
|
|
|
|
const device = devices.get(device_id);
|
|
|
|
|
if (device) {
|
|
|
|
|
device.save('active', true);
|
|
|
|
|
} else {
|
|
|
|
|
devices.create({'id': device_id, 'jid': jid})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (u.isSameBareJID(jid, _converse.bare_jid)) {
|
|
|
|
|
// Make sure our own device is on the list
|
|
|
|
|
// (i.e. if it was removed, add it again).
|
|
|
|
|
devicelist.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) {
|
|
|
|
|
log.error(e.message);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}, null, 'message', 'headline');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreOMEMOSession () {
|
|
|
|
|
if (_converse.omemo_store === undefined) {
|
|
|
|
|
const id = `converse.omemosession-${_converse.bare_jid}`;
|
|
|
|
|
_converse.omemo_store = new _converse.OMEMOStore({'id': id});
|
|
|
|
|
_converse.omemo_store.browserStorage = _converse.createStore(id);
|
|
|
|
|
}
|
|
|
|
|
return _converse.omemo_store.fetchSession();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function fetchDeviceLists () {
|
|
|
|
|
return new Promise((success, error) => _converse.devicelists.fetch({success, 'error': (m, e) => error(e)}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchOwnDevices () {
|
|
|
|
|
await fetchDeviceLists();
|
|
|
|
|
let own_devicelist = _converse.devicelists.get(_converse.bare_jid);
|
|
|
|
|
if (own_devicelist) {
|
|
|
|
|
own_devicelist.fetchDevices();
|
|
|
|
|
} else {
|
|
|
|
|
own_devicelist = await _converse.devicelists.create({'jid': _converse.bare_jid}, {'promise': true});
|
|
|
|
|
}
|
|
|
|
|
return own_devicelist._devices_promise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function initOMEMO () {
|
2020-10-20 06:45:35 +02:00
|
|
|
|
if (!_converse.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
|
|
|
|
|
log.warn("Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true");
|
2020-06-08 12:12:49 +02:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_converse.devicelists = new _converse.DeviceLists();
|
|
|
|
|
const id = `converse.devicelists-${_converse.bare_jid}`;
|
|
|
|
|
_converse.devicelists.browserStorage = _converse.createStore(id);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await fetchOwnDevices();
|
|
|
|
|
await restoreOMEMOSession();
|
|
|
|
|
await _converse.omemo_store.publishBundle();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
log.error("Could not initialize OMEMO support");
|
|
|
|
|
log.error(e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
/**
|
2020-09-06 12:30:30 +02:00
|
|
|
|
* Triggered once OMEMO support has been initialized
|
|
|
|
|
* @event _converse#OMEMOInitialized
|
|
|
|
|
* @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */
|
2020-06-08 12:12:49 +02:00
|
|
|
|
api.trigger('OMEMOInitialized');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onOccupantAdded (chatroom, occupant) {
|
|
|
|
|
if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (chatroom.get('omemo_active')) {
|
|
|
|
|
const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid'));
|
|
|
|
|
if (!supported) {
|
|
|
|
|
chatroom.createMessage({
|
|
|
|
|
'message': __("%1$s doesn't appear to have a client that supports OMEMO. " +
|
|
|
|
|
"Encrypted chat will no longer be possible in this grouchat.", occupant.get('nick')),
|
|
|
|
|
'type': 'error'
|
|
|
|
|
});
|
|
|
|
|
chatroom.save({'omemo_active': false, 'omemo_supported': false});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkOMEMOSupported (chatbox) {
|
|
|
|
|
let supported;
|
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
await api.waitUntil('OMEMOInitialized');
|
|
|
|
|
supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
|
|
|
|
|
} else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
|
|
|
|
|
supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid'));
|
|
|
|
|
}
|
|
|
|
|
chatbox.set('omemo_supported', supported);
|
|
|
|
|
if (supported && api.settings.get('omemo_default')) {
|
|
|
|
|
chatbox.set('omemo_active', true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleOMEMO (ev) {
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
|
|
|
|
|
if (!toolbar_el.model.get('omemo_supported')) {
|
|
|
|
|
let messages;
|
|
|
|
|
if (toolbar_el.model.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
messages = [__(
|
2020-08-17 09:03:33 +02:00
|
|
|
|
'Cannot use end-to-end encryption in this groupchat, '+
|
2020-06-08 12:12:49 +02:00
|
|
|
|
'either the groupchat has some anonymity or not all participants support OMEMO.'
|
|
|
|
|
)];
|
|
|
|
|
} else {
|
|
|
|
|
messages = [__(
|
|
|
|
|
"Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
|
|
|
|
|
toolbar_el.model.contact.getDisplayName()
|
|
|
|
|
)];
|
|
|
|
|
}
|
|
|
|
|
return api.alert('error', __('Error'), messages);
|
|
|
|
|
}
|
|
|
|
|
toolbar_el.model.save({'omemo_active': !toolbar_el.model.get('omemo_active')});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getOMEMOToolbarButton (toolbar_el, buttons) {
|
|
|
|
|
const model = toolbar_el.model;
|
|
|
|
|
const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
|
|
|
|
|
let title;
|
|
|
|
|
if (is_muc && model.get('omemo_supported')) {
|
|
|
|
|
const i18n_plaintext = __('Messages are being sent in plaintext');
|
|
|
|
|
const i18n_encrypted = __('Messages are sent encrypted');
|
|
|
|
|
title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
|
|
|
|
|
} else {
|
|
|
|
|
title = __('This groupchat needs to be members-only and non-anonymous in '+
|
|
|
|
|
'order to support OMEMO encrypted messages');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
buttons.push(html`
|
|
|
|
|
<button class="toggle-omemo"
|
|
|
|
|
title="${title}"
|
|
|
|
|
?disabled=${!model.get('omemo_supported')}
|
|
|
|
|
@click=${toggleOMEMO}>
|
|
|
|
|
<converse-icon class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
|
|
|
|
|
path-prefix="${api.settings.get('assets_path')}" size="1em"
|
2020-10-07 11:32:29 +02:00
|
|
|
|
color="${model.get('omemo_active') ? `var(--info-color)` : `var(--error-color)`}"
|
2020-06-08 12:12:49 +02:00
|
|
|
|
></converse-icon>
|
|
|
|
|
</button>`
|
|
|
|
|
);
|
|
|
|
|
return buttons;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2020-11-18 10:49:31 +01:00
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
converse.plugins.add('converse-omemo', {
|
|
|
|
|
|
|
|
|
|
enabled (_converse) {
|
2020-10-20 06:45:35 +02:00
|
|
|
|
return window.libsignal &&
|
2020-11-02 13:22:00 +01:00
|
|
|
|
_converse.config.get('trusted') &&
|
|
|
|
|
!api.settings.get('clear_cache_on_logout') &&
|
|
|
|
|
!_converse.api.settings.get("blacklisted_plugins").includes('converse-omemo');
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
|
|
|
|
|
2019-08-08 16:20:55 +02:00
|
|
|
|
dependencies: ["converse-chatview", "converse-pubsub", "converse-profile"],
|
2018-10-23 03:41:38 +02:00
|
|
|
|
|
|
|
|
|
overrides: {
|
|
|
|
|
|
|
|
|
|
ProfileModal: {
|
|
|
|
|
events: {
|
|
|
|
|
'change input.select-all': 'selectAll',
|
2018-11-12 15:12:33 +01:00
|
|
|
|
'click .generate-bundle': 'generateOMEMODeviceBundle',
|
2018-10-23 03:41:38 +02:00
|
|
|
|
'submit .fingerprint-removal': 'removeSelectedFingerprints'
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
initialize () {
|
2020-03-06 15:19:48 +01:00
|
|
|
|
this.debouncedRender = debounce(this.render, 50);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
this.devicelist = _converse.devicelists.get(_converse.bare_jid);
|
2019-09-06 14:34:59 +02:00
|
|
|
|
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);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return this.__super__.initialize.apply(this, arguments);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
beforeRender () {
|
2020-05-18 10:54:37 +02:00
|
|
|
|
const device_id = _converse.omemo_store.get('device_id');
|
2018-12-13 09:48:43 +01:00
|
|
|
|
|
2018-11-12 15:12:33 +01:00
|
|
|
|
if (device_id) {
|
|
|
|
|
this.current_device = this.devicelist.devices.get(device_id);
|
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
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;
|
2018-05-20 10:16:18 +02:00
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
removeSelectedFingerprints (ev) {
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
ev.stopPropagation();
|
|
|
|
|
ev.target.querySelector('.select-all').checked = false
|
2019-09-10 15:47:30 +02:00
|
|
|
|
const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map(c => c.value);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
this.devicelist.removeOwnDevices(device_ids)
|
|
|
|
|
.then(this.modal.hide)
|
|
|
|
|
.catch(err => {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
log.error(err);
|
2019-10-31 14:42:28 +01:00
|
|
|
|
_converse.api.alert(
|
2018-10-23 03:41:38 +02:00
|
|
|
|
Strophe.LogLevel.ERROR,
|
|
|
|
|
__('Error'), [__('Sorry, an error occurred while trying to remove the devices.')]
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
},
|
2018-11-12 15:12:33 +01:00
|
|
|
|
|
|
|
|
|
generateOMEMODeviceBundle (ev) {
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
if (confirm(__(
|
2018-11-22 09:09:08 +01:00
|
|
|
|
"Are you sure you want to generate new OMEMO keys? " +
|
2018-12-19 05:48:11 +01:00
|
|
|
|
"This will remove your old keys and all previously encrypted messages will no longer be decryptable on this device.")
|
2018-11-12 15:12:33 +01:00
|
|
|
|
)) {
|
|
|
|
|
api.omemo.bundle.generate();
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-05-11 22:05:45 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
UserDetailsModal: {
|
|
|
|
|
events: {
|
|
|
|
|
'click .fingerprint-trust .btn input': 'toggleDeviceTrust'
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
initialize () {
|
|
|
|
|
const jid = this.model.get('jid');
|
2019-03-30 18:01:36 +01:00
|
|
|
|
this.devicelist = _converse.devicelists.getDeviceList(jid);
|
2019-09-06 14:34:59 +02:00
|
|
|
|
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);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return this.__super__.initialize.apply(this, arguments);
|
|
|
|
|
},
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
toggleDeviceTrust (ev) {
|
|
|
|
|
const radio = ev.target;
|
|
|
|
|
const device = this.devicelist.devices.get(radio.getAttribute('name'));
|
|
|
|
|
device.save('trusted', parseInt(radio.value, 10));
|
|
|
|
|
}
|
2018-05-11 22:05:45 +02:00
|
|
|
|
},
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
ChatBox: {
|
2019-05-24 13:52:15 +02:00
|
|
|
|
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;
|
2019-07-30 15:50:46 +02:00
|
|
|
|
let message, stanza;
|
2019-05-24 13:52:15 +02:00
|
|
|
|
try {
|
|
|
|
|
const devices = await _converse.getBundlesAndBuildSessions(this);
|
2020-03-02 16:59:18 +01:00
|
|
|
|
message = await this.createMessage(attrs);
|
2019-07-30 15:50:46 +02:00
|
|
|
|
stanza = await _converse.createOMEMOMessageStanza(this, message, devices);
|
2019-05-24 13:52:15 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
this.handleMessageSendError(e);
|
2019-07-30 15:50:46 +02:00
|
|
|
|
return null;
|
2019-05-24 13:52:15 +02:00
|
|
|
|
}
|
2019-07-30 15:50:46 +02:00
|
|
|
|
_converse.api.send(stanza);
|
|
|
|
|
return message;
|
2019-05-24 13:52:15 +02:00
|
|
|
|
} else {
|
|
|
|
|
return this.__super__.sendMessage.apply(this, arguments);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
initialize () {
|
|
|
|
|
/* The initialize function gets called as soon as the plugin is
|
|
|
|
|
* loaded by Converse.js's plugin machinery.
|
|
|
|
|
*/
|
|
|
|
|
|
2020-06-03 09:17:13 +02:00
|
|
|
|
api.settings.extend({'omemo_default': false});
|
2020-03-31 13:15:57 +02:00
|
|
|
|
api.promises.add(['OMEMOInitialized']);
|
2019-05-24 13:52:15 +02:00
|
|
|
|
|
|
|
|
|
_converse.NUM_PREKEYS = 100; // Set here so that tests can override
|
|
|
|
|
|
|
|
|
|
Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox);
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2018-05-11 22:05:45 +02:00
|
|
|
|
|
2018-11-12 15:12:33 +01:00
|
|
|
|
_converse.generateFingerprints = async function (jid) {
|
2018-12-18 11:21:04 +01:00
|
|
|
|
const devices = await getDevicesForContact(jid)
|
2018-11-12 15:12:33 +01:00
|
|
|
|
return Promise.all(devices.map(d => generateFingerprint(d)));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_converse.getDeviceForContact = function (jid, device_id) {
|
2018-12-18 11:21:04 +01:00
|
|
|
|
return getDevicesForContact(jid).then(devices => devices.get(device_id));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-11-09 17:36:31 +01:00
|
|
|
|
_converse.contactHasOMEMOSupport = async function (jid) {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
/* Checks whether the contact advertises any OMEMO-compatible devices. */
|
2018-12-18 11:21:04 +01:00
|
|
|
|
const devices = await getDevicesForContact(jid);
|
2018-11-09 17:36:31 +01:00
|
|
|
|
return devices.length > 0;
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
2018-08-27 14:25:34 +02:00
|
|
|
|
|
2018-12-18 11:21:04 +01:00
|
|
|
|
_converse.getBundlesAndBuildSessions = async function (chatbox) {
|
2019-07-05 19:15:23 +02:00
|
|
|
|
const no_devices_err = __("Sorry, no devices found to which we can send an OMEMO encrypted message.");
|
2018-12-18 11:21:04 +01:00
|
|
|
|
let devices;
|
|
|
|
|
if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
|
|
|
|
|
const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
|
2020-03-06 15:19:48 +01:00
|
|
|
|
devices = collections.reduce((a, b) => concat(a, b.models), []);
|
2018-12-18 11:21:04 +01:00
|
|
|
|
} else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
|
2019-03-30 16:30:48 +01:00
|
|
|
|
const their_devices = await getDevicesForContact(chatbox.get('jid'));
|
|
|
|
|
if (their_devices.length === 0) {
|
2019-07-05 19:15:23 +02:00
|
|
|
|
const err = new Error(no_devices_err);
|
2019-03-30 16:30:48 +01:00
|
|
|
|
err.user_facing = true;
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
const own_devices = _converse.devicelists.get(_converse.bare_jid).devices;
|
2019-07-05 19:15:23 +02:00
|
|
|
|
devices = [...own_devices.models, ...their_devices.models];
|
2018-12-18 11:21:04 +01:00
|
|
|
|
}
|
2019-07-05 19:15:23 +02:00
|
|
|
|
// Filter out our own device
|
|
|
|
|
const id = _converse.omemo_store.get('device_id');
|
|
|
|
|
devices = devices.filter(d => d.get('id') !== id);
|
2020-09-18 13:27:51 +02:00
|
|
|
|
// Fetch bundles if necessary
|
2018-12-18 11:21:04 +01:00
|
|
|
|
await Promise.all(devices.map(d => d.getBundle()));
|
2020-09-18 13:27:51 +02:00
|
|
|
|
|
|
|
|
|
const sessions = devices.filter(d => d).map(d => getSession(d));
|
|
|
|
|
await Promise.all(sessions);
|
2019-07-05 19:15:23 +02:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2018-12-18 11:21:04 +01:00
|
|
|
|
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})
|
2019-03-29 23:07:45 +01:00
|
|
|
|
.c('header', {'sid': _converse.omemo_store.get('device_id')});
|
2018-12-18 11:21:04 +01:00
|
|
|
|
|
2020-09-06 12:30:30 +02:00
|
|
|
|
return omemo.encryptMessage(message.get('message')).then(obj => {
|
2018-12-18 11:21:04 +01:00
|
|
|
|
// 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
|
2019-03-30 18:01:36 +01:00
|
|
|
|
.filter(device => (device.get('trusted') != UNTRUSTED && device.get('active')))
|
2018-12-18 11:21:04 +01:00
|
|
|
|
.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;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
|
2019-09-19 16:54:55 +02:00
|
|
|
|
_converse.OMEMOStore = Model.extend({
|
2018-07-22 10:33:57 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
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)
|
2018-07-22 10:33:57 +02:00
|
|
|
|
});
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-07-22 10:33:57 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
getLocalRegistrationId () {
|
|
|
|
|
return Promise.resolve(parseInt(this.get('device_id'), 10));
|
|
|
|
|
},
|
2018-07-22 10:33:57 +02:00
|
|
|
|
|
2019-10-09 16:01:38 +02:00
|
|
|
|
isTrustedIdentity (identifier, identity_key, direction) { // eslint-disable-line no-unused-vars
|
2019-08-05 01:39:57 +02:00
|
|
|
|
if (identifier === null || identifier === undefined) {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
throw new Error("Can't check identity key for invalid key");
|
2018-05-15 19:34:24 +02:00
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
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) {
|
2019-08-05 01:39:57 +02:00
|
|
|
|
if (identifier === null || identifier === undefined) {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
throw new Error("Can't load identity_key for invalid identifier");
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key'+identifier)));
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
saveIdentity (identifier, identity_key) {
|
2019-08-05 01:39:57 +02:00
|
|
|
|
if (identifier === null || identifier === undefined) {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
throw new Error("Can't save identity_key for invalid identifier");
|
|
|
|
|
}
|
2019-10-24 14:29:15 +02:00
|
|
|
|
const address = new libsignal.SignalProtocolAddress.fromString(identifier);
|
|
|
|
|
const existing = this.get('identity_key'+address.getName());
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const b64_idkey = u.arrayBufferToBase64(identity_key);
|
|
|
|
|
this.save('identity_key'+address.getName(), b64_idkey)
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
if (existing && b64_idkey !== existing) {
|
|
|
|
|
return Promise.resolve(true);
|
|
|
|
|
} else {
|
|
|
|
|
return Promise.resolve(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
getPreKeys () {
|
|
|
|
|
return this.get('prekeys') || {};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
loadPreKey (key_id) {
|
|
|
|
|
const res = this.getPreKeys()[key_id];
|
|
|
|
|
if (res) {
|
2018-08-23 12:28:39 +02:00
|
|
|
|
return Promise.resolve({
|
2018-10-23 03:41:38 +02:00
|
|
|
|
'privKey': u.base64ToArrayBuffer(res.privKey),
|
|
|
|
|
'pubKey': u.base64ToArrayBuffer(res.pubKey)
|
2018-08-23 12:28:39 +02:00
|
|
|
|
});
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
storePreKey (key_id, key_pair) {
|
|
|
|
|
const prekey = {};
|
|
|
|
|
prekey[key_id] = {
|
|
|
|
|
'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
|
|
|
|
|
'privKey': u.arrayBufferToBase64(key_pair.privKey)
|
|
|
|
|
}
|
2019-04-29 09:07:15 +02:00
|
|
|
|
this.save('prekeys', Object.assign(this.getPreKeys(), prekey));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
removePreKey (key_id) {
|
2020-03-06 15:19:48 +01:00
|
|
|
|
this.save('prekeys', omit(this.getPreKeys(), key_id));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2019-10-09 16:01:38 +02:00
|
|
|
|
loadSignedPreKey (keyId) { // eslint-disable-line no-unused-vars
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const res = this.get('signed_prekey');
|
|
|
|
|
if (res) {
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
'privKey': u.base64ToArrayBuffer(res.privKey),
|
|
|
|
|
'pubKey': u.base64ToArrayBuffer(res.pubKey)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
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();
|
|
|
|
|
},
|
2018-08-23 12:28:39 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
removeSignedPreKey (key_id) {
|
|
|
|
|
if (this.get('signed_prekey')['id'] === key_id) {
|
|
|
|
|
this.unset('signed_prekey');
|
|
|
|
|
this.save();
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
2018-08-23 12:28:39 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
loadSession (identifier) {
|
|
|
|
|
return Promise.resolve(this.get('session'+identifier));
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
storeSession (identifier, record) {
|
|
|
|
|
return Promise.resolve(this.save('session'+identifier, record));
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
removeSession (identifier) {
|
|
|
|
|
return Promise.resolve(this.unset('session'+identifier));
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
removeAllSessions (identifier) {
|
2020-03-06 15:19:48 +01:00
|
|
|
|
const keys = Object.keys(this.attributes).filter(key => key.startsWith('session'+identifier) ? key : false);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const attrs = {};
|
2020-03-06 15:19:48 +01:00
|
|
|
|
keys.forEach(key => {attrs[key] = undefined});
|
2018-10-23 03:41:38 +02:00
|
|
|
|
this.save(attrs);
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
publishBundle () {
|
|
|
|
|
const signed_prekey = this.get('signed_prekey');
|
2018-12-19 08:14:58 +01:00
|
|
|
|
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');
|
2020-03-06 15:19:48 +01:00
|
|
|
|
|
|
|
|
|
Object.values(this.get('prekeys')).forEach((prekey, id) => item.c('preKeyPublic', {'preKeyId': id}).t(prekey.pubKey).up());
|
2018-12-19 08:14:58 +01:00
|
|
|
|
const options = {'pubsub#access_model': 'open'};
|
2020-03-31 13:15:57 +02:00
|
|
|
|
return api.pubsub.publish(null, node, item, options, false);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-05-15 19:34:24 +02:00
|
|
|
|
|
2018-12-19 17:18:36 +01:00
|
|
|
|
async generateMissingPreKeys () {
|
2020-03-06 15:19:48 +01:00
|
|
|
|
const missing_keys = difference(
|
|
|
|
|
invokeMap(range(0, _converse.NUM_PREKEYS), Number.prototype.toString),
|
2019-09-10 15:47:30 +02:00
|
|
|
|
Object.keys(this.getPreKeys())
|
|
|
|
|
);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
if (missing_keys.length < 1) {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
log.warn("No missing prekeys to generate for our own device");
|
2018-05-15 19:34:24 +02:00
|
|
|
|
return Promise.resolve();
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
2019-09-10 15:47:30 +02:00
|
|
|
|
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'));
|
2018-12-19 17:18:36 +01:00
|
|
|
|
const bundle = await device.getBundle();
|
2019-04-29 09:07:15 +02:00
|
|
|
|
device.save('bundle', Object.assign(bundle, {'prekeys': marshalled_keys}));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-10-17 23:58:11 +02:00
|
|
|
|
|
2020-09-06 12:30:30 +02:00
|
|
|
|
/**
|
|
|
|
|
* Generate a the data used by the X3DH key agreement protocol
|
|
|
|
|
* that can be used to build a session with a device.
|
|
|
|
|
*/
|
2018-10-23 03:41:38 +02:00
|
|
|
|
async generateBundle () {
|
2020-09-06 12:30:30 +02:00
|
|
|
|
// 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.
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
|
2020-09-06 12:30:30 +02:00
|
|
|
|
const bundle = {};
|
|
|
|
|
const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
|
|
|
|
|
const device_id = generateDeviceID();
|
2018-10-23 03:41:38 +02:00
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2020-03-06 15:19:48 +01:00
|
|
|
|
const keys = await Promise.all(range(0, _converse.NUM_PREKEYS).map(id => libsignal.KeyHelper.generatePreKey(id)));
|
2019-09-10 15:47:30 +02:00
|
|
|
|
keys.forEach(k => _converse.omemo_store.storePreKey(k.keyId, k.keyPair));
|
|
|
|
|
const devicelist = _converse.devicelists.get(_converse.bare_jid);
|
2020-02-03 22:39:50 +01:00
|
|
|
|
const device = await devicelist.devices.create({'id': bundle.device_id, 'jid': _converse.bare_jid}, {'promise': true});
|
2019-09-10 15:47:30 +02:00
|
|
|
|
const marshalled_keys = keys.map(k => ({'id': k.keyId, 'key': u.arrayBufferToBase64(k.keyPair.pubKey)}));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
bundle['prekeys'] = marshalled_keys;
|
|
|
|
|
device.save('bundle', bundle);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
fetchSession () {
|
2019-07-29 10:19:05 +02:00
|
|
|
|
if (this._setup_promise === undefined) {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
this._setup_promise = new Promise((resolve, reject) => {
|
|
|
|
|
this.fetch({
|
|
|
|
|
'success': () => {
|
|
|
|
|
if (!_converse.omemo_store.get('device_id')) {
|
2019-03-31 12:01:19 +02:00
|
|
|
|
this.generateBundle().then(resolve).catch(reject);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
} else {
|
|
|
|
|
resolve();
|
2018-05-11 17:31:49 +02:00
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2019-03-31 19:36:04 +02:00
|
|
|
|
'error': (model, resp) => {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
log.warn("Could not fetch OMEMO session from cache, we'll generate a new one.");
|
|
|
|
|
log.warn(resp);
|
2019-03-31 12:01:19 +02:00
|
|
|
|
this.generateBundle().then(resolve).catch(reject);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
2018-05-11 17:31:49 +02:00
|
|
|
|
});
|
2018-10-23 03:41:38 +02:00
|
|
|
|
});
|
2018-05-11 17:31:49 +02:00
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return this._setup_promise;
|
|
|
|
|
}
|
|
|
|
|
});
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2020-09-18 13:27:51 +02:00
|
|
|
|
/**
|
|
|
|
|
* @class
|
|
|
|
|
* @namespace _converse.Device
|
|
|
|
|
* @memberOf _converse
|
|
|
|
|
*/
|
2019-09-19 16:54:55 +02:00
|
|
|
|
_converse.Device = Model.extend({
|
2018-10-23 03:41:38 +02:00
|
|
|
|
defaults: {
|
2019-03-30 18:01:36 +01:00
|
|
|
|
'trusted': UNDECIDED,
|
|
|
|
|
'active': true
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getRandomPreKey () {
|
|
|
|
|
// XXX: assumes that the bundle has already been fetched
|
|
|
|
|
const bundle = this.get('bundle');
|
|
|
|
|
return bundle.prekeys[u.getRandomInt(bundle.prekeys.length)];
|
|
|
|
|
},
|
|
|
|
|
|
2018-12-18 11:21:04 +01:00
|
|
|
|
async fetchBundleFromServer () {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
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')}`});
|
|
|
|
|
|
2018-12-18 11:21:04 +01:00
|
|
|
|
let iq;
|
|
|
|
|
try {
|
2020-03-31 13:15:57 +02:00
|
|
|
|
iq = await api.sendIQ(stanza)
|
2018-12-19 17:18:36 +01:00
|
|
|
|
} catch (iq) {
|
2020-09-18 13:27:51 +02:00
|
|
|
|
log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`);
|
|
|
|
|
log.error(iq);
|
|
|
|
|
return null;
|
2018-12-19 17:18:36 +01:00
|
|
|
|
}
|
|
|
|
|
if (iq.querySelector('error')) {
|
|
|
|
|
throw new IQError("Could not fetch bundle", iq);
|
2018-12-18 11:21:04 +01:00
|
|
|
|
}
|
2020-09-18 13:27:51 +02:00
|
|
|
|
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);
|
2018-12-18 11:21:04 +01:00
|
|
|
|
this.save('bundle', bundle);
|
|
|
|
|
return bundle;
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
|
|
|
|
|
2020-09-18 13:27:51 +02:00
|
|
|
|
/**
|
|
|
|
|
* Fetch and save the bundle information associated with
|
|
|
|
|
* this device, if the information is not cached already.
|
|
|
|
|
* @method _converse.Device#getBundle
|
|
|
|
|
*/
|
2018-10-23 03:41:38 +02:00
|
|
|
|
getBundle () {
|
|
|
|
|
if (this.get('bundle')) {
|
|
|
|
|
return Promise.resolve(this.get('bundle'), this);
|
|
|
|
|
} else {
|
|
|
|
|
return this.fetchBundleFromServer();
|
2018-05-11 17:31:49 +02:00
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
});
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2019-09-19 16:54:55 +02:00
|
|
|
|
_converse.Devices = Collection.extend({
|
2018-10-23 03:41:38 +02:00
|
|
|
|
model: _converse.Device,
|
|
|
|
|
});
|
|
|
|
|
|
2019-03-30 18:01:36 +01:00
|
|
|
|
/**
|
|
|
|
|
* @class
|
|
|
|
|
* @namespace _converse.DeviceList
|
|
|
|
|
* @memberOf _converse
|
|
|
|
|
*/
|
2019-09-19 16:54:55 +02:00
|
|
|
|
_converse.DeviceList = Model.extend({
|
2018-10-23 03:41:38 +02:00
|
|
|
|
idAttribute: 'jid',
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
initialize () {
|
|
|
|
|
this.devices = new _converse.Devices();
|
|
|
|
|
const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
|
2019-10-24 14:29:15 +02:00
|
|
|
|
this.devices.browserStorage = _converse.createStore(id);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
this.fetchDevices();
|
2020-02-03 22:18:12 +01:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
|
|
|
|
|
2019-03-30 17:01:11 +01:00
|
|
|
|
async onDevicesFound (collection) {
|
|
|
|
|
if (collection.length === 0) {
|
|
|
|
|
let ids;
|
|
|
|
|
try {
|
|
|
|
|
ids = await this.fetchDevicesFromServer()
|
|
|
|
|
} catch (e) {
|
2019-08-10 13:18:56 +02:00
|
|
|
|
if (e === null) {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
log.error(`Timeout error while fetching devices for ${this.get('jid')}`);
|
2019-08-10 13:18:56 +02:00
|
|
|
|
} else {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
log.error(`Could not fetch devices for ${this.get('jid')}`);
|
|
|
|
|
log.error(e);
|
2019-08-10 13:18:56 +02:00
|
|
|
|
}
|
2019-03-30 17:01:11 +01:00
|
|
|
|
this.destroy();
|
|
|
|
|
}
|
|
|
|
|
if (this.get('jid') === _converse.bare_jid) {
|
|
|
|
|
await this.publishCurrentDevice(ids);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
fetchDevices () {
|
2019-07-29 10:19:05 +02:00
|
|
|
|
if (this._devices_promise === undefined) {
|
2018-11-12 11:46:18 +01:00
|
|
|
|
this._devices_promise = new Promise(resolve => {
|
|
|
|
|
this.devices.fetch({
|
2019-09-07 22:00:28 +02:00
|
|
|
|
'success': c => resolve(this.onDevicesFound(c)),
|
2019-11-06 11:01:34 +01:00
|
|
|
|
'error': (m, e) => { log.error(e); resolve(); }
|
2018-11-12 11:46:18 +01:00
|
|
|
|
});
|
|
|
|
|
});
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
return this._devices_promise;
|
|
|
|
|
},
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2020-09-06 12:30:30 +02:00
|
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
async publishCurrentDevice (device_ids) {
|
|
|
|
|
if (this.get('jid') !== _converse.bare_jid) {
|
2019-03-30 17:01:11 +01:00
|
|
|
|
return // We only publish for ourselves.
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
await restoreOMEMOSession();
|
2020-07-27 10:47:31 +02:00
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2020-09-06 12:30:30 +02:00
|
|
|
|
if (!device_ids.includes(await this.getOwnDeviceId())) {
|
2018-08-21 16:19:57 +02:00
|
|
|
|
return this.publishDevices();
|
2018-05-11 17:31:49 +02:00
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2018-11-09 17:36:31 +01:00
|
|
|
|
async fetchDevicesFromServer () {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
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});
|
2018-11-09 17:36:31 +01:00
|
|
|
|
|
|
|
|
|
let iq;
|
|
|
|
|
try {
|
2020-03-31 13:15:57 +02:00
|
|
|
|
iq = await api.sendIQ(stanza);
|
2018-11-09 17:36:31 +01:00
|
|
|
|
} catch (e) {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
log.error(e);
|
2018-11-09 17:36:31 +01:00
|
|
|
|
return [];
|
|
|
|
|
}
|
2020-02-03 22:18:12 +01:00
|
|
|
|
const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`;
|
|
|
|
|
const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id'));
|
2020-02-03 22:39:50 +01:00
|
|
|
|
await Promise.all(
|
|
|
|
|
device_ids.map(id => this.devices.create({id, 'jid': this.get('jid')}, {'promise': true}))
|
|
|
|
|
);
|
2018-11-09 17:36:31 +01:00
|
|
|
|
return device_ids;
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2020-09-06 12:30:30 +02:00
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2018-10-23 03:41:38 +02:00
|
|
|
|
publishDevices () {
|
2020-09-18 12:49:02 +02:00
|
|
|
|
const item = $build('item', {'id': 'current'}).c('list', {'xmlns': Strophe.NS.OMEMO})
|
2019-03-30 18:01:36 +01:00
|
|
|
|
this.devices.filter(d => d.get('active')).forEach(d => item.c('device', {'id': d.get('id')}).up());
|
2018-12-19 08:14:58 +01:00
|
|
|
|
const options = {'pubsub#access_model': 'open'};
|
2020-03-31 13:15:57 +02:00
|
|
|
|
return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false);
|
2018-10-23 03:41:38 +02:00
|
|
|
|
},
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2018-10-23 03:41:38 +02:00
|
|
|
|
removeOwnDevices (device_ids) {
|
|
|
|
|
if (this.get('jid') !== _converse.bare_jid) {
|
|
|
|
|
throw new Error("Cannot remove devices from someone else's device list");
|
|
|
|
|
}
|
2020-03-06 15:19:48 +01:00
|
|
|
|
device_ids.forEach(device_id => this.devices.get(device_id).destroy());
|
2018-10-23 03:41:38 +02:00
|
|
|
|
return this.publishDevices();
|
2018-05-11 17:31:49 +02:00
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
});
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2019-03-30 18:01:36 +01:00
|
|
|
|
/**
|
|
|
|
|
* @class
|
|
|
|
|
* @namespace _converse.DeviceLists
|
|
|
|
|
* @memberOf _converse
|
|
|
|
|
*/
|
2019-09-19 16:54:55 +02:00
|
|
|
|
_converse.DeviceLists = Collection.extend({
|
2018-10-23 03:41:38 +02:00
|
|
|
|
model: _converse.DeviceList,
|
2019-03-30 18:01:36 +01:00
|
|
|
|
/**
|
|
|
|
|
* 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});
|
|
|
|
|
}
|
2018-10-23 03:41:38 +02:00
|
|
|
|
});
|
2018-05-11 17:31:49 +02:00
|
|
|
|
|
2019-08-01 10:26:35 +02:00
|
|
|
|
/******************** Event Handlers ********************/
|
2020-11-18 10:49:31 +01:00
|
|
|
|
api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
|
|
|
|
|
|
2020-09-06 12:30:30 +02:00
|
|
|
|
api.listen.on('parseMessage', parseEncryptedMessage);
|
|
|
|
|
api.listen.on('parseMUCMessage', parseEncryptedMessage);
|
2019-08-01 10:26:35 +02:00
|
|
|
|
|
2020-06-08 12:12:49 +02:00
|
|
|
|
api.listen.on('chatBoxViewInitialized', onChatInitialized);
|
|
|
|
|
api.listen.on('chatRoomViewInitialized', onChatInitialized);
|
|
|
|
|
|
2020-03-31 13:15:57 +02:00
|
|
|
|
api.listen.on('connected', registerPEPPushHandler);
|
2020-06-08 12:12:49 +02:00
|
|
|
|
api.listen.on('getToolbarButtons', getOMEMOToolbarButton);
|
|
|
|
|
|
2020-03-31 13:15:57 +02:00
|
|
|
|
api.listen.on('statusInitialized', initOMEMO);
|
|
|
|
|
api.listen.on('addClientFeatures',
|
|
|
|
|
() => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
|
2020-11-18 10:49:31 +01:00
|
|
|
|
api.listen.on('userDetailsModalInitialized', contact => {
|
2018-10-23 03:41:38 +02:00
|
|
|
|
const jid = contact.get('jid');
|
2019-11-06 11:01:34 +01:00
|
|
|
|
_converse.generateFingerprints(jid).catch(e => log.error(e));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
});
|
|
|
|
|
|
2020-03-31 13:15:57 +02:00
|
|
|
|
api.listen.on('profileModalInitialized', () => {
|
2019-11-06 11:01:34 +01:00
|
|
|
|
_converse.generateFingerprints(_converse.bare_jid).catch(e => log.error(e));
|
2018-10-23 03:41:38 +02:00
|
|
|
|
});
|
2018-11-12 15:12:33 +01:00
|
|
|
|
|
2020-03-31 13:15:57 +02:00
|
|
|
|
api.listen.on('afterTearDown', () => (delete _converse.omemo_store));
|
2019-08-01 10:26:35 +02:00
|
|
|
|
|
2020-03-31 13:15:57 +02:00
|
|
|
|
api.listen.on('clearSession', () => {
|
2019-08-01 10:26:35 +02:00
|
|
|
|
if (_converse.shouldClearCache() && _converse.devicelists) {
|
2019-09-19 16:54:55 +02:00
|
|
|
|
_converse.devicelists.clearStore();
|
2019-08-01 10:26:35 +02:00
|
|
|
|
delete _converse.devicelists;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/************************ API ************************/
|
2019-04-29 09:07:15 +02:00
|
|
|
|
Object.assign(_converse.api, {
|
2018-11-12 15:12:33 +01:00
|
|
|
|
/**
|
|
|
|
|
* 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
|
2020-09-06 12:30:30 +02:00
|
|
|
|
const devicelist = _converse.devicelists.get(_converse.bare_jid);
|
|
|
|
|
const device_id = _converse.omemo_store.get('device_id');
|
2018-11-12 15:12:33 +01:00
|
|
|
|
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');
|
|
|
|
|
}
|
2020-09-18 12:56:10 +02:00
|
|
|
|
// Generate new device bundle and publish
|
|
|
|
|
// https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing
|
2018-11-12 15:12:33 +01:00
|
|
|
|
await _converse.omemo_store.generateBundle();
|
|
|
|
|
await devicelist.publishDevices();
|
|
|
|
|
const device = devicelist.devices.get(_converse.omemo_store.get('device_id'));
|
2020-09-18 12:56:10 +02:00
|
|
|
|
const fp = generateFingerprint(device);
|
|
|
|
|
await _converse.omemo_store.publishBundle();
|
|
|
|
|
return fp;
|
2018-11-12 15:12:33 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2018-10-23 03:41:38 +02:00
|
|
|
|
}
|
|
|
|
|
});
|