Commit 7c43d043 authored by JC Brand's avatar JC Brand

Refactor OMEMO.

- Add hooks to the stanza parsers so that plugins can do additional parsing.
- Change ChatBox instance methods to functions and use them for stanza parsing.
- Move encrypt and decrypt messages to `converse.env.omemo`

Apparently, when receving a 1:1 carbon message, a device was wrongly created
for the contact's device list, instead of our own.
parent fce337e3
...@@ -7307,9 +7307,9 @@ ...@@ -7307,9 +7307,9 @@
}, },
"dependencies": { "dependencies": {
"dot-prop": { "dot-prop": {
"version": "5.2.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
"integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==", "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
"dev": true, "dev": true,
"requires": { "requires": {
"is-obj": "^2.0.0" "is-obj": "^2.0.0"
...@@ -11651,9 +11651,9 @@ ...@@ -11651,9 +11651,9 @@
} }
}, },
"git-url-parse": { "git-url-parse": {
"version": "11.1.3", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.3.tgz", "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.2.0.tgz",
"integrity": "sha512-GPsfwticcu52WQ+eHp0IYkAyaOASgYdtsQDIt4rUp6GbiNt1P9ddrh3O0kQB0eD4UJZszVqNT3+9Zwcg40fywA==", "integrity": "sha512-KPoHZg8v+plarZvto4ruIzzJLFQoRx+sUs5DQSr07By9IBKguVd+e6jwrFR6/TP6xrCJlNV1tPqLO1aREc7O2g==",
"dev": true, "dev": true,
"requires": { "requires": {
"git-up": "^4.0.0" "git-up": "^4.0.0"
...@@ -14712,9 +14712,9 @@ ...@@ -14712,9 +14712,9 @@
} }
}, },
"node-fetch": { "node-fetch": {
"version": "2.6.0", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==", "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
"dev": true "dev": true
}, },
"node-fetch-npm": { "node-fetch-npm": {
...@@ -21278,9 +21278,9 @@ ...@@ -21278,9 +21278,9 @@
} }
}, },
"rxjs": { "rxjs": {
"version": "6.6.2", "version": "6.6.3",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz",
"integrity": "sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==", "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"tslib": "^1.9.0" "tslib": "^1.9.0"
......
/*global mock, converse */ /*global mock, converse */
const { $iq, $pres, $msg, _, Strophe } = converse.env; const { $iq, $pres, $msg, _, omemo, Strophe } = converse.env;
const u = converse.env.utils; const u = converse.env.utils;
async function deviceListFetched (_converse, jid) { async function deviceListFetched (_converse, jid) {
...@@ -78,15 +78,12 @@ describe("The OMEMO module", function() { ...@@ -78,15 +78,12 @@ describe("The OMEMO module", function() {
const message = 'This message will be encrypted' const message = 'This message will be encrypted'
await mock.waitForRoster(_converse, 'current', 1); await mock.waitForRoster(_converse, 'current', 1);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const payload = await omemo.encryptMessage(message);
const view = await mock.openChatBoxFor(_converse, contact_jid); const result = await omemo.decryptMessage(payload);
const payload = await view.model.encryptMessage(message);
const result = await view.model.decryptMessage(payload);
expect(result).toBe(message); expect(result).toBe(message);
done(); done();
})); }));
it("enables encrypted messages to be sent and received", it("enables encrypted messages to be sent and received",
mock.initConverse( mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {}, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
...@@ -182,10 +179,9 @@ describe("The OMEMO module", function() { ...@@ -182,10 +179,9 @@ describe("The OMEMO module", function() {
`</message>`); `</message>`);
// Test reception of an encrypted message // Test reception of an encrypted message
let obj = await view.model.encryptMessage('This is an encrypted message from the contact') let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
// XXX: Normally the key will be encrypted via libsignal. // XXX: Normally the key will be encrypted via libsignal.
// However, we're mocking libsignal in the tests, so we include // However, we're mocking libsignal in the tests, so we include it as plaintext in the message.
// it as plaintext in the message.
stanza = $msg({ stanza = $msg({
'from': contact_jid, 'from': contact_jid,
'to': _converse.connection.jid, 'to': _converse.connection.jid,
...@@ -205,7 +201,7 @@ describe("The OMEMO module", function() { ...@@ -205,7 +201,7 @@ describe("The OMEMO module", function() {
.toBe('This is an encrypted message from the contact'); .toBe('This is an encrypted message from the contact');
// #1193 Check for a received message without <body> tag // #1193 Check for a received message without <body> tag
obj = await view.model.encryptMessage('Another received encrypted message without fallback') obj = await omemo.encryptMessage('Another received encrypted message without fallback')
stanza = $msg({ stanza = $msg({
'from': contact_jid, 'from': contact_jid,
'to': _converse.connection.jid, 'to': _converse.connection.jid,
...@@ -383,6 +379,9 @@ describe("The OMEMO module", function() { ...@@ -383,6 +379,9 @@ describe("The OMEMO module", function() {
await u.waitUntil(() => initializedOMEMO(_converse)); await u.waitUntil(() => initializedOMEMO(_converse));
await mock.openChatBoxFor(_converse, contact_jid); await mock.openChatBoxFor(_converse, contact_jid);
let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid)); let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
const my_devicelist = _converse.devicelists.get({'jid': _converse.bare_jid});
expect(my_devicelist.devices.length).toBe(2);
const stanza = $iq({ const stanza = $iq({
'from': contact_jid, 'from': contact_jid,
'id': iq_stanza.getAttribute('id'), 'id': iq_stanza.getAttribute('id'),
...@@ -395,14 +394,15 @@ describe("The OMEMO module", function() { ...@@ -395,14 +394,15 @@ describe("The OMEMO module", function() {
.c('device', {'id': '555'}); .c('device', {'id': '555'});
_converse.connection._dataRecv(mock.createRequest(stanza)); _converse.connection._dataRecv(mock.createRequest(stanza));
await u.waitUntil(() => _converse.omemo_store); await u.waitUntil(() => _converse.omemo_store);
const devicelist = _converse.devicelists.get({'jid': contact_jid});
await u.waitUntil(() => devicelist.devices.length === 1); const contact_devicelist = _converse.devicelists.get({'jid': contact_jid});
await u.waitUntil(() => contact_devicelist.devices.length === 1);
const view = _converse.chatboxviews.get(contact_jid); const view = _converse.chatboxviews.get(contact_jid);
view.model.set('omemo_active', true); view.model.set('omemo_active', true);
// Test reception of an encrypted carbon message // Test reception of an encrypted carbon message
const obj = await view.model.encryptMessage('This is an encrypted carbon message from another device of mine') const obj = await omemo.encryptMessage('This is an encrypted carbon message from another device of mine')
const carbon = u.toStanza(` const carbon = u.toStanza(`
<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat"> <message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat">
<sent xmlns="urn:xmpp:carbons:2"> <sent xmlns="urn:xmpp:carbons:2">
...@@ -440,10 +440,14 @@ describe("The OMEMO module", function() { ...@@ -440,10 +440,14 @@ describe("The OMEMO module", function() {
expect(view.el.querySelector('.chat-msg__text').textContent.trim()) expect(view.el.querySelector('.chat-msg__text').textContent.trim())
.toBe('This is an encrypted carbon message from another device of mine'); .toBe('This is an encrypted carbon message from another device of mine');
expect(devicelist.devices.length).toBe(2); expect(contact_devicelist.devices.length).toBe(1);
expect(devicelist.devices.at(0).get('id')).toBe('555');
expect(devicelist.devices.at(1).get('id')).toBe('988349631'); // Check that the new device id has been added to my devices
expect(devicelist.devices.get('988349631').get('active')).toBe(true); expect(my_devicelist.devices.length).toBe(3);
expect(my_devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe');
expect(my_devicelist.devices.at(1).get('id')).toBe('123456789');
expect(my_devicelist.devices.at(2).get('id')).toBe('988349631');
expect(my_devicelist.devices.get('988349631').get('active')).toBe(true);
const textarea = view.el.querySelector('.chat-textarea'); const textarea = view.el.querySelector('.chat-textarea');
textarea.value = 'This is an encrypted message from this device'; textarea.value = 'This is an encrypted message from this device';
...@@ -601,7 +605,7 @@ describe("The OMEMO module", function() { ...@@ -601,7 +605,7 @@ describe("The OMEMO module", function() {
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await u.waitUntil(() => initializedOMEMO(_converse)); await u.waitUntil(() => initializedOMEMO(_converse));
const obj = await _converse.ChatBox.prototype.encryptMessage('This is an encrypted message from the contact'); const obj = await omemo.encryptMessage('This is an encrypted message from the contact');
// XXX: Normally the key will be encrypted via libsignal. // XXX: Normally the key will be encrypted via libsignal.
// However, we're mocking libsignal in the tests, so we include // However, we're mocking libsignal in the tests, so we include
// it as plaintext in the message. // it as plaintext in the message.
...@@ -631,8 +635,8 @@ describe("The OMEMO module", function() { ...@@ -631,8 +635,8 @@ describe("The OMEMO module", function() {
return generateMissingPreKeys.apply(_converse.omemo_store, arguments); return generateMissingPreKeys.apply(_converse.omemo_store, arguments);
}); });
_converse.connection._dataRecv(mock.createRequest(stanza)); _converse.connection._dataRecv(mock.createRequest(stanza));
let iq_stanza = await u.waitUntil(() => _converse.chatboxviews.get(contact_jid));
iq_stanza = await deviceListFetched(_converse, contact_jid); let iq_stanza = await deviceListFetched(_converse, contact_jid);
stanza = $iq({ stanza = $iq({
'from': contact_jid, 'from': contact_jid,
'id': iq_stanza.getAttribute('id'), 'id': iq_stanza.getAttribute('id'),
...@@ -688,7 +692,6 @@ describe("The OMEMO module", function() { ...@@ -688,7 +692,6 @@ describe("The OMEMO module", function() {
done(); done();
})); }));
it("updates device lists based on PEP messages", it("updates device lists based on PEP messages",
mock.initConverse( mock.initConverse(
['rosterGroupsFetched'], {'allow_non_roster_messaging': true}, ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
......
...@@ -40,6 +40,149 @@ class IQError extends Error { ...@@ -40,6 +40,149 @@ class IQError extends Error {
} }
} }
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) {
const session_cipher = getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
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));
}
}
function addKeysToMessageStanza (stanza, dicts, iv) { function addKeysToMessageStanza (stanza, dicts, iv) {
for (const i in dicts) { for (const i in dicts) {
...@@ -84,7 +227,6 @@ function parseBundle (bundle_el) { ...@@ -84,7 +227,6 @@ function parseBundle (bundle_el) {
} }
} }
async function generateFingerprint (device) { async function generateFingerprint (device) {
if (device.get('bundle')?.fingerprint) { if (device.get('bundle')?.fingerprint) {
return; return;
...@@ -107,11 +249,14 @@ function generateDeviceID () { ...@@ -107,11 +249,14 @@ function generateDeviceID () {
/* Generates a device ID, making sure that it's unique */ /* Generates a device ID, making sure that it's unique */
const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id'); const existing_ids = _converse.devicelists.get(_converse.bare_jid).devices.pluck('id');
let device_id = libsignal.KeyHelper.generateRegistrationId(); let device_id = libsignal.KeyHelper.generateRegistrationId();
// 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.
let i = 0; let i = 0;
while (existing_ids.includes(device_id)) { while (existing_ids.includes(device_id)) {
device_id = libsignal.KeyHelper.generateRegistrationId(); device_id = libsignal.KeyHelper.generateRegistrationId();
i++; i++;
if (i == 10) { if (i === 10) {
throw new Error("Unable to generate a unique device ID"); throw new Error("Unable to generate a unique device ID");
} }
} }
...@@ -119,10 +264,13 @@ function generateDeviceID () { ...@@ -119,10 +264,13 @@ function generateDeviceID () {
} }
async function buildSession (device) { async function buildSession (device) {
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')), // TODO: check device-get('jid') versus the 'from' attribute which is used
sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address), // to build a session when receiving an encrypted message in a MUC.
prekey = device.getRandomPreKey(), // https://github.com/conversejs/converse.js/issues/1481#issuecomment-509183431
bundle = await device.getBundle(); 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();
return sessionBuilder.processPreKey({ return sessionBuilder.processPreKey({
'registrationId': parseInt(device.get('id'), 10), 'registrationId': parseInt(device.get('id'), 10),
...@@ -143,7 +291,7 @@ async function getSession (device) { ...@@ -143,7 +291,7 @@ async function getSession (device) {
const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')); const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
const session = await _converse.omemo_store.loadSession(address.toString()); const session = await _converse.omemo_store.loadSession(address.toString());
if (session) { if (session) {
return Promise.resolve(session); return session;
} else { } else {
try { try {
const session = await buildSession(device); const session = await buildSession(device);
...@@ -161,11 +309,11 @@ function updateBundleFromStanza (stanza) { ...@@ -161,11 +309,11 @@ function updateBundleFromStanza (stanza) {
if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) { if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
return; return;
} }
const device_id = items_el.getAttribute('node').split(':')[1], const device_id = items_el.getAttribute('node').split(':')[1];
jid = stanza.getAttribute('from'), const jid = stanza.getAttribute('from');
bundle_el = sizzle(`item > bundle`, items_el).pop(), const bundle_el = sizzle(`item > bundle`, items_el).pop();
devicelist = _converse.devicelists.getDeviceList(jid), const devicelist = _converse.devicelists.getDeviceList(jid);
device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid}); const device = devicelist.devices.get(device_id) || devicelist.devices.create({'id': device_id, 'jid': jid});
device.save({'bundle': parseBundle(bundle_el)}); device.save({'bundle': parseBundle(bundle_el)});
} }
...@@ -262,8 +410,7 @@ async function initOMEMO () { ...@@ -262,8 +410,7 @@ async function initOMEMO () {
/** /**
* Triggered once OMEMO support has been initialized * Triggered once OMEMO support has been initialized
* @event _converse#OMEMOInitialized * @event _converse#OMEMOInitialized
* @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); */
*/
api.trigger('OMEMOInitialized'); api.trigger('OMEMOInitialized');
} }
...@@ -298,7 +445,6 @@ async function checkOMEMOSupported (chatbox) { ...@@ -298,7 +445,6 @@ async function checkOMEMOSupported (chatbox) {
} }
} }
function toggleOMEMO (ev) { function toggleOMEMO (ev) {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
...@@ -491,120 +637,8 @@ converse.plugins.add('converse-omemo', { ...@@ -491,120 +637,8 @@ converse.plugins.add('converse-omemo', {
*/ */
const OMEMOEnabledChatBox = { const OMEMOEnabledChatBox = {
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 Promise.resolve({
'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));
},
reportDecryptionError (e) {
if (api.settings.get("loglevel") === 'debug') {
this.createMessage({
'message': __("Sorry, could not decrypt a received OMEMO message due to an error.") + ` ${e.name} ${e.message}`,
'type': 'error',
});
}
log.error(`${e.name} ${e.message}`);
},
async handleDecryptedWhisperMessage (attrs, key_and_tag) {
const encrypted = attrs.encrypted;
const devicelist = _converse.devicelists.getDeviceList(this.get('jid'));
await devicelist._devices_promise;
this.save('omemo_supported', true);
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 this.decryptMessage(Object.assign(encrypted, {'key': key, 'tag': tag}));
device.save('active', true);
return result;
}
},
async decrypt (attrs) {
const session_cipher = this.getSessionCipher(attrs.from, parseInt(attrs.encrypted.device_id, 10));
// https://xmpp.org/extensions/xep-0384.html#usecases-receiving
const key = u.base64ToArrayBuffer(attrs.encrypted.key);
if (attrs.encrypted.prekey === true) {
try {
const key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
const plaintext = await this.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) {
this.reportDecryptionError(e);
return attrs;
}
} else {
try {
const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary')
const plaintext = await this.handleDecryptedWhisperMessage(attrs, key_and_tag);
return Object.assign(attrs, {'plaintext': plaintext});
} catch (e) {
this.reportDecryptionError(e);
return attrs;
}
}
},
getSessionCipher (jid, id) {
const address = new libsignal.SignalProtocolAddress(jid, id);
this.session_cipher = new window.libsignal.SessionCipher(_converse.omemo_store, address);
return this.session_cipher;
},
encryptKey (plaintext, device) { encryptKey (plaintext, device) {
return this.getSessionCipher(device.get('jid'), device.get('id')) return getSessionCipher(device.get('jid'), device.get('id'))
.encrypt(plaintext) .encrypt(plaintext)
.then(payload => ({'payload': payload, 'device': device})); .then(payload => ({'payload': payload, 'device': device}));
}, },
...@@ -713,7 +747,7 @@ converse.plugins.add('converse-omemo', { ...@@ -713,7 +747,7 @@ converse.plugins.add('converse-omemo', {
stanza.c('encrypted', {'xmlns': Strophe.NS.OMEMO}) stanza.c('encrypted', {'xmlns': Strophe.NS.OMEMO})
.c('header', {'sid': _converse.omemo_store.get('device_id')}); .c('header', {'sid': _converse.omemo_store.get('device_id')});
return chatbox.encryptMessage(message.get('message')).then(obj => { return omemo.encryptMessage(message.get('message')).then(obj => {
// The 16 bytes key and the GCM authentication tag (The tag // The 16 bytes key and the GCM authentication tag (The tag
// SHOULD have at least 128 bit) are concatenated and for each // SHOULD have at least 128 bit) are concatenated and for each
// intended recipient device, i.e. both own devices as well as // intended recipient device, i.e. both own devices as well as
...@@ -916,17 +950,20 @@ converse.plugins.add('converse-omemo', { ...@@ -916,17 +950,20 @@ converse.plugins.add('converse-omemo', {
device.save('bundle', Object.assign(bundle, {'prekeys': marshalled_keys})); device.save('bundle', Object.assign(bundle, {'prekeys': marshalled_keys}));
}, },
async generateBundle () { /**
/* The first thing that needs to happen if a client wants to * Generate a the data used by the X3DH key agreement protocol
* start using OMEMO is they need to generate an IdentityKey * that can be used to build a session with a device.
* 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.
*/ */
async generateBundle () {
// The first thing that needs to happen if a client wants to
// start using OMEMO is they need to generate an IdentityKey
// and a Device ID. The IdentityKey is a Curve25519 [6]
// public/private Key pair. The Device ID is a randomly
// generated integer between 1 and 2^31 - 1.
const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair(); const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
const bundle = {}, const bundle = {};
identity_key = u.arrayBufferToBase64(identity_keypair.pubKey), const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
device_id = generateDeviceID(); const device_id = generateDeviceID();
bundle['identity_key'] = identity_key; bundle['identity_key'] = identity_key;
bundle['device_id'] = device_id; bundle['device_id'] = device_id;
...@@ -1078,6 +1115,16 @@ converse.plugins.add('converse-omemo', { ...@@ -1078,6 +1115,16 @@ converse.plugins.add('converse-omemo', {
return this._devices_promise; return this._devices_promise;
}, },
async getOwnDeviceId () {
let device_id = _converse.omemo_store.get('device_id');
if (!this.devices.findWhere({'id': device_id})) {
// Generate a new bundle if we cannot find our device
await _converse.omemo_store.generateBundle();
device_id = _converse.omemo_store.get('device_id');
}
return device_id;
},
async publishCurrentDevice (device_ids) { async publishCurrentDevice (device_ids) {
if (this.get('jid') !== _converse.bare_jid) { if (this.get('jid') !== _converse.bare_jid) {
return // We only publish for ourselves. return // We only publish for ourselves.
...@@ -1090,14 +1137,7 @@ converse.plugins.add('converse-omemo', { ...@@ -1090,14 +1137,7 @@ converse.plugins.add('converse-omemo', {
log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue'); log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
return; return;
} }
if (!device_ids.includes(await this.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');
}
if (!device_ids.includes(device_id)) {
return this.publishDevices(); return this.publishDevices();
} }
}, },
...@@ -1125,6 +1165,11 @@ converse.plugins.add('converse-omemo', { ...@@ -1125,6 +1165,11 @@ converse.plugins.add('converse-omemo', {
return device_ids; return device_ids;
}, },
/**
* Send an IQ stanza to the current user's "devices" PEP node to
* ensure that all devices are published for potential chat partners to see.
* See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing
*/
publishDevices () { publishDevices () {
const item = $build('item').c('list', {'xmlns': Strophe.NS.OMEMO}) const item = $build('item').c('list', {'xmlns': Strophe.NS.OMEMO})
this.devices.filter(d => d.get('active')).forEach(d => item.c('device', {'id': d.get('id')}).up()); this.devices.filter(d => d.get('active')).forEach(d => item.c('device', {'id': d.get('id')}).up());
...@@ -1160,8 +1205,22 @@ converse.plugins.add('converse-omemo', { ...@@ -1160,8 +1205,22 @@ converse.plugins.add('converse-omemo', {
} }
}); });
function parseEncryptedMessage (attrs) {
if (attrs.is_encrypted) {
// https://xmpp.org/extensions/xep-0384.html#usecases-receiving
if (attrs.encrypted.prekey === true) {
return decryptPrekeyWhisperMessage(attrs);
} else {
return decryptWhisperMessage(attrs);
}
} else {
return attrs;
}
}
/******************** Event Handlers ********************/ /******************** Event Handlers ********************/
api.listen.on('parseMessage', parseEncryptedMessage);
api.listen.on('parseMUCMessage', parseEncryptedMessage);
api.waitUntil('chatBoxesInitialized').then(() => api.waitUntil('chatBoxesInitialized').then(() =>
_converse.chatboxes.on('add', chatbox => { _converse.chatboxes.on('add', chatbox => {
...@@ -1174,6 +1233,11 @@ converse.plugins.add('converse-omemo', { ...@@ -1174,6 +1233,11 @@ converse.plugins.add('converse-omemo', {
); );
const onChatInitialized = view => { const 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', () => { view.listenTo(view.model, 'change:omemo_supported', () => {
if (!view.model.get('omemo_supported') && view.model.get('omemo_active')) { if (!view.model.get('omemo_supported') && view.model.get('omemo_active')) {
view.model.set('omemo_active', false); view.model.set('omemo_active', false);
...@@ -1244,8 +1308,8 @@ converse.plugins.add('converse-omemo', { ...@@ -1244,8 +1308,8 @@ converse.plugins.add('converse-omemo', {
*/ */
'generate': async () => { 'generate': async () => {
// Remove current device // Remove current device
const devicelist = _converse.devicelists.get(_converse.bare_jid), const devicelist = _converse.devicelists.get(_converse.bare_jid);
device_id = _converse.omemo_store.get('device_id'); const device_id = _converse.omemo_store.get('device_id');
if (device_id) { if (device_id) {
const device = devicelist.devices.get(device_id); const device = devicelist.devices.get(device_id);
_converse.omemo_store.unset(device_id); _converse.omemo_store.unset(device_id);
......
...@@ -457,8 +457,6 @@ converse.plugins.add('converse-chat', { ...@@ -457,8 +457,6 @@ converse.plugins.add('converse-chat', {
attrs.stanza && log.error(attrs.stanza); attrs.stanza && log.error(attrs.stanza);
return log.error(attrs.message); return log.error(attrs.message);
} }
// TODO: move to OMEMO
attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs;
const message = this.getDuplicateMessage(attrs); const message = this.getDuplicateMessage(attrs);
if (message) { if (message) {
this.updateMessage(message, attrs); this.updateMessage(message, attrs);
...@@ -1215,7 +1213,7 @@ converse.plugins.add('converse-chat', { ...@@ -1215,7 +1213,7 @@ converse.plugins.add('converse-chat', {
} }
const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length; const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length;
const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body); const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body);
chatbox && await chatbox.queueMessage(attrs); await chatbox?.queueMessage(attrs);
/** /**
* Triggered when a message stanza is been received and processed. * Triggered when a message stanza is been received and processed.
* @event _converse#message * @event _converse#message
......
...@@ -1993,8 +1993,6 @@ converse.plugins.add('converse-muc', { ...@@ -1993,8 +1993,6 @@ converse.plugins.add('converse-muc', {
attrs.stanza && log.error(attrs.stanza); attrs.stanza && log.error(attrs.stanza);
return log.error(attrs.message); return log.error(attrs.message);
} }
// TODO: move to OMEMO
attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs;
const message = this.getDuplicateMessage(attrs); const message = this.getDuplicateMessage(attrs);
if (message) { if (message) {
return this.updateMessage(message, attrs); return this.updateMessage(message, attrs);
......
...@@ -362,7 +362,6 @@ const st = { ...@@ -362,7 +362,6 @@ const st = {
}, {}); }, {});
}, },
/** /**
* Parses a passed in message stanza and returns an object of attributes. * Parses a passed in message stanza and returns an object of attributes.
* @method st#parseMessage * @method st#parseMessage
...@@ -418,7 +417,6 @@ const st = { ...@@ -418,7 +417,6 @@ const st = {
); );
} }
const is_headline = st.isHeadline(stanza); const is_headline = st.isHeadline(stanza);
const is_server_message = st.isServerMessage(stanza); const is_server_message = st.isServerMessage(stanza);
let contact, contact_jid; let contact, contact_jid;
...@@ -534,7 +532,12 @@ const st = { ...@@ -534,7 +532,12 @@ const st = {
// We prefer to use one of the XEP-0359 unique and stable stanza IDs // We prefer to use one of the XEP-0359 unique and stable stanza IDs
// as the Model id, to avoid duplicates. // as the Model id, to avoid duplicates.
attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId(); attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId();
return attrs;
/**
* *Hook* which allows plugins to add additional parsing
* @event _converse#parseMessage
*/
return api.hook('parseMessage', attrs);
}, },
/** /**
...@@ -678,7 +681,11 @@ const st = { ...@@ -678,7 +681,11 @@ const st = {
} }
// We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates. // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId(); attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId();
return attrs; /**
* *Hook* which allows plugins to add additional parsing
* @event _converse#parseMUCMessage
*/
return api.hook('parseMUCMessage', attrs);
}, },
/** /**
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment