Jelajahi Sumber

Add XEP-0454 support for encrypting files

Fixes #1182
JC Brand 4 tahun lalu
induk
melakukan
e675c853f3

+ 1 - 0
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

+ 1 - 1
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
 

+ 12 - 4
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();
                 }

+ 7 - 0
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.',

+ 5 - 0
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);
 

+ 11 - 11
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);

+ 71 - 43
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);
 
-        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 = {
+async function 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));
+    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`
-        <button class="toggle-omemo" title="${title}" ?disabled=${!model.get('omemo_supported')} @click=${toggleOMEMO}>
+        <button class="toggle-omemo" title="${title}" data-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"
-                color="${model.get('omemo_active') ? `var(--info-color)` : `var(--error-color)`}"
+                color="${color}"
             ></converse-icon>
         </button>
     `);

+ 7 - 3
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`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
                 ${ (this.model.get('edited')) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }
             </span>
-            ${ this.model.get('oob_url') ? html`<div class="chat-msg__media">${u.getOOBURLMarkup(_converse, this.model.get('oob_url'))}</div>` : '' }
+            ${ this.model.get('oob_url') ? html`<div class="chat-msg__media">${getOOBURLMarkup(this.model.get('oob_url'))}</div>` : '' }
             <div class="chat-msg__error">${ this.model.get('error_text') || this.model.get('error') }</div>
         `;
     }

+ 4 - 3
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;