From e675c853f3b1600882b28a10c5000262b931a228 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Thu, 1 Jul 2021 12:57:28 +0200 Subject: [PATCH] Add XEP-0454 support for encrypting files Fixes #1182 --- CHANGES.md | 1 + README.md | 2 +- src/headless/plugins/chat/message.js | 16 +++- src/headless/plugins/chat/model.js | 7 ++ src/plugins/omemo/index.js | 5 ++ src/plugins/omemo/tests/omemo.js | 22 +++--- src/plugins/omemo/utils.js | 112 +++++++++++++++++---------- src/shared/chat/message.js | 10 ++- src/utils/html.js | 7 +- 9 files changed, 118 insertions(+), 64 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 713e14924..68789186c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,7 @@ ## 8.0.0 (Unreleased) - #1083: Add support for XEP-0393 Message Styling +- #1182: Add support for XEP-0454 OMEMO Media sharing - #2275: Allow punctuation to immediately precede a mention - #2348: `auto_join_room` not showing the room in `fullscreen` `view_mode`. - #2400: Fixes infinite loop bug when appending .png to allowed image urls diff --git a/README.md b/README.md index 039b57a3b..833bc0f4d 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ In embedded mode, Converse can be embedded into an element in the DOM. - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions - [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation - [XEP-0437](https://xmpp.org/extensions/xep-0437.html) Room Activity Indicators - +- [XEP-0454](https://xmpp.org/extensions/xep-0454.html) OMEMO Media sharing ## Integration into other servers and frameworks diff --git a/src/headless/plugins/chat/message.js b/src/headless/plugins/chat/message.js index ed6830647..e735cecf4 100644 --- a/src/headless/plugins/chat/message.js +++ b/src/headless/plugins/chat/message.js @@ -224,15 +224,23 @@ const MessageMixin = { uploadFile () { const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = () => { + xhr.onreadystatechange = async () => { if (xhr.readyState === XMLHttpRequest.DONE) { log.info('Status: ' + xhr.status); if (xhr.status === 200 || xhr.status === 201) { - this.save({ + let attrs = { 'upload': _converse.SUCCESS, 'oob_url': this.get('get'), - 'message': this.get('get') - }); + 'message': this.get('get'), + 'body': this.get('get'), + }; + /** + * *Hook* which allows plugins to change the attributes + * saved on the message once a file has been uploaded. + * @event _converse#afterFileUploaded + */ + attrs = await api.hook('afterFileUploaded', this, attrs); + this.save(attrs); } else { xhr.onerror(); } diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index 1869dba0f..21df447e5 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -1002,6 +1002,13 @@ const ChatBox = ModelWithContact.extend({ return; } Array.from(files).forEach(async file => { + /** + * *Hook* which allows plugins to transform files before they'll be + * uploaded. The main use-case is to encrypt the files. + * @event _converse#beforeFileUpload + */ + file = await api.hook('beforeFileUpload', this, file); + if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) { return this.createMessage({ 'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.', diff --git a/src/plugins/omemo/index.js b/src/plugins/omemo/index.js index 40d40a74d..3ecfef36c 100644 --- a/src/plugins/omemo/index.js +++ b/src/plugins/omemo/index.js @@ -19,6 +19,7 @@ import omemo_api from './api.js'; import { OMEMOEnabledChatBox } from './mixins/chatbox.js'; import { _converse, api, converse } from '@converse/headless/core'; import { + encryptFile, getOMEMOToolbarButton, handleEncryptedFiles, initOMEMO, @@ -26,6 +27,7 @@ import { onChatBoxesInitialized, onChatInitialized, parseEncryptedMessage, + setEncryptedFileURL, registerPEPPushHandler, } from './utils.js'; @@ -72,6 +74,9 @@ converse.plugins.add('converse-omemo', { /******************** Event Handlers ********************/ api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized); + api.listen.on('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs); + api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file); + api.listen.on('parseMessage', parseEncryptedMessage); api.listen.on('parseMUCMessage', parseEncryptedMessage); diff --git a/src/plugins/omemo/tests/omemo.js b/src/plugins/omemo/tests/omemo.js index 8f384009e..befd6d912 100644 --- a/src/plugins/omemo/tests/omemo.js +++ b/src/plugins/omemo/tests/omemo.js @@ -1229,13 +1229,13 @@ describe("The OMEMO module", function() { }); view.model.save({'omemo_supported': false}); - await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').disabled); - icon = toolbar.querySelector('.toggle-omemo converse-icon'); + await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')?.dataset.disabled === "true"); + icon = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo converse-icon')); expect(u.hasClass('fa-lock', icon)).toBe(false); expect(u.hasClass('fa-unlock', icon)).toBe(true); view.model.save({'omemo_supported': true}); - await u.waitUntil(() => !toolbar.querySelector('.toggle-omemo').disabled); + await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')?.dataset.disabled === "false"); icon = toolbar.querySelector('.toggle-omemo converse-icon'); expect(u.hasClass('fa-lock', icon)).toBe(false); expect(u.hasClass('fa-unlock', icon)).toBe(true); @@ -1270,7 +1270,7 @@ describe("The OMEMO module", function() { let toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); expect(view.model.get('omemo_active')).toBe(undefined); expect(view.model.get('omemo_supported')).toBe(true); - await u.waitUntil(() => !toggle.disabled); + await u.waitUntil(() => toggle.dataset.disabled === "false"); let icon = toolbar.querySelector('.toggle-omemo converse-icon'); expect(u.hasClass('fa-unlock', icon)).toBe(true); @@ -1278,7 +1278,7 @@ describe("The OMEMO module", function() { toggle.click(); toggle = toolbar.querySelector('.toggle-omemo'); - expect(!!toggle.disabled).toBe(false); + expect(toggle.dataset.disabled).toBe("false"); expect(view.model.get('omemo_active')).toBe(true); expect(view.model.get('omemo_supported')).toBe(true); @@ -1330,7 +1330,7 @@ describe("The OMEMO module", function() { expect(view.model.get('omemo_active')).toBe(true); toggle = toolbar.querySelector('.toggle-omemo'); expect(toggle === null).toBe(false); - expect(!!toggle.disabled).toBe(false); + expect(toggle.dataset.disabled).toBe("false"); expect(view.model.get('omemo_supported')).toBe(true); await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))); @@ -1340,23 +1340,23 @@ describe("The OMEMO module", function() { // anonymous or semi-anonymous view.model.features.save({'nonanonymous': false, 'semianonymous': true}); await u.waitUntil(() => !view.model.get('omemo_supported')); - await u.waitUntil(() => view.querySelector('.toggle-omemo').disabled); + await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "true"); view.model.features.save({'nonanonymous': true, 'semianonymous': false}); await u.waitUntil(() => view.model.get('omemo_supported')); await u.waitUntil(() => view.querySelector('.toggle-omemo') !== null); expect(u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true); expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(false); - expect(!!view.querySelector('.toggle-omemo').disabled).toBe(false); + expect(view.querySelector('.toggle-omemo').dataset.disabled).toBe("false"); // Test that the button gets disabled when the room becomes open view.model.features.save({'membersonly': false, 'open': true}); await u.waitUntil(() => !view.model.get('omemo_supported')); - await u.waitUntil(() => view.querySelector('.toggle-omemo').disabled); + await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "true"); view.model.features.save({'membersonly': true, 'open': false}); await u.waitUntil(() => view.model.get('omemo_supported')); - await u.waitUntil(() => !view.querySelector('.toggle-omemo').disabled); + await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "false"); expect(u.hasClass('fa-unlock', view.querySelector('.toggle-omemo converse-icon'))).toBe(true); expect(u.hasClass('fa-lock', view.querySelector('.toggle-omemo converse-icon'))).toBe(false); @@ -1404,7 +1404,7 @@ describe("The OMEMO module", function() { "Encrypted chat will no longer be possible in this grouchat." ); - await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').disabled); + await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').dataset.disabled === "true"); icon = view.querySelector('.toggle-omemo converse-icon'); expect(u.hasClass('fa-unlock', icon)).toBe(true); expect(u.hasClass('fa-lock', icon)).toBe(false); diff --git a/src/plugins/omemo/utils.js b/src/plugins/omemo/utils.js index a6ea599a8..acc2dc88f 100644 --- a/src/plugins/omemo/utils.js +++ b/src/plugins/omemo/utils.js @@ -31,50 +31,73 @@ const KEY_ALGO = { 'length': 128 }; -export const 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, 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); +async function encryptMessage (plaintext) { + // The client MUST use fresh, randomly generated key/IV pairs + // with AES-128 in Galois/Counter Mode (GCM). - return { - 'key': exported_key, - 'tag': tag, - 'key_and_tag': appendArrayBuffer(exported_key, tag), - 'payload': arrayBufferToBase64(ciphertext), - 'iv': arrayBufferToBase64(iv) - }; - }, - - async decryptMessage (obj) { - const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']); - const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag); - const algo = { + // 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)); + const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']); + const algo = { 'name': 'AES-GCM', - 'iv': base64ToArrayBuffer(obj.iv), + 'iv': iv, 'tagLength': TAG_LENGTH }; - return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher)); - } + const encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext)); + const length = encrypted.byteLength - ((128 + 7) >> 3); + const ciphertext = encrypted.slice(0, length); + const tag = encrypted.slice(length); + const exported_key = await crypto.subtle.exportKey('raw', key); + return { + 'key': exported_key, + 'tag': tag, + 'key_and_tag': appendArrayBuffer(exported_key, tag), + 'payload': arrayBufferToBase64(ciphertext), + 'iv': arrayBufferToBase64(iv) + }; +} + +async function decryptMessage (obj) { + const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']); + const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag); + const algo = { + 'name': 'AES-GCM', + 'iv': base64ToArrayBuffer(obj.iv), + 'tagLength': TAG_LENGTH + }; + return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher)); +} + +export const omemo = { + decryptMessage, + encryptMessage +} + + +export async function encryptFile (file) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt']); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, }, key, await file.arrayBuffer()); + const exported_key = await window.crypto.subtle.exportKey('raw', key); + const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified }); + encrypted_file.xep454_ivkey = arrayBufferToHex(iv) + arrayBufferToHex(exported_key); + return encrypted_file; +} + +export function setEncryptedFileURL (message, attrs) { + const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey; + return Object.assign(attrs, { + 'oob_url': null, // Since only the body gets encrypted, we don't set the oob_url + 'message': url, + 'body': url + }); } async function decryptFile (iv, key, cipher) { @@ -646,14 +669,19 @@ export function getOMEMOToolbarButton (toolbar_el, buttons) { 'order to support OMEMO encrypted messages' ); } - + let color; + if (model.get('omemo_supported')) { + color = model.get('omemo_active') ? `var(--info-color)` : `var(--error-color)`; + } else { + color = `var(--muc-toolbar-btn-disabled-color)`; + } buttons.push(html` - `); diff --git a/src/shared/chat/message.js b/src/shared/chat/message.js index c29cef517..d7cbd3f2c 100644 --- a/src/shared/chat/message.js +++ b/src/shared/chat/message.js @@ -13,11 +13,11 @@ import { CustomElement } from 'shared/components/element.js'; import { __ } from 'i18n'; import { _converse, api, converse } from '@converse/headless/core'; import { getHats } from './utils.js'; +import { getOOBURLMarkup } from 'utils/html.js'; import { html } from 'lit'; import { renderAvatar } from 'shared/directives/avatar'; const { Strophe, dayjs } = converse.env; -const u = converse.env.utils; export default class Message extends CustomElement { @@ -70,7 +70,7 @@ export default class Message extends CustomElement { return ''; } else if (this.show_spinner) { return tpl_spinner(); - } else if (this.model.get('file') && !this.model.get('oob_url')) { + } else if (this.model.get('file') && this.model.get('upload') !== _converse.SUCCESS) { return this.renderFileProgress(); } else if (['error', 'info'].includes(this.model.get('type'))) { return this.renderInfoMessage(); @@ -105,6 +105,10 @@ export default class Message extends CustomElement { } renderFileProgress () { + if (!this.model.file) { + // Can happen when file upload failed and page was reloaded + return ''; + } const i18n_uploading = __('Uploading file:'); const filename = this.model.file.name; const size = filesize(this.model.file.size); @@ -264,7 +268,7 @@ export default class Message extends CustomElement { ${ (this.model.get('received') && !this.model.isMeCommand() && !is_groupchat_message) ? html`` : '' } ${ (this.model.get('edited')) ? html`` : '' } - ${ this.model.get('oob_url') ? html`
${u.getOOBURLMarkup(_converse, this.model.get('oob_url'))}
` : '' } + ${ this.model.get('oob_url') ? html`
${getOOBURLMarkup(this.model.get('oob_url'))}
` : '' }
${ this.model.get('error_text') || this.model.get('error') }
`; } diff --git a/src/utils/html.js b/src/utils/html.js index 3bb1e8d76..b19d3bd18 100644 --- a/src/utils/html.js +++ b/src/utils/html.js @@ -161,7 +161,7 @@ function getFileName (uri) { * @param { String } url * @returns { String } */ -u.getOOBURLMarkup = function (_converse, url) { +export function getOOBURLMarkup (url) { const uri = getURI(url); if (uri === null) { return url; @@ -175,7 +175,7 @@ u.getOOBURLMarkup = function (_converse, url) { } else { return tpl_file(uri.toString(), getFileName(uri)); } -}; +} /** * Return the height of the passed in DOM element, @@ -598,7 +598,8 @@ Object.assign(u, { isImageURL, isImageDomainAllowed, isURLWithImageExtension, - isVideoURL + isVideoURL, + getOOBURLMarkup, }); export default u;