Browse Source

Add support for decrypting XEP-0454 OMEMO media

JC Brand 4 years ago
parent
commit
7848d8cb2f

+ 5 - 0
src/headless/utils/arraybuffer.js

@@ -39,5 +39,10 @@ export function base64ToArrayBuffer (b64) {
     return bytes.buffer
     return bytes.buffer
 }
 }
 
 
+export function hexToArrayBuffer (hex) {
+    const typedArray = new Uint8Array(hex.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16)))
+    return typedArray.buffer
+}
+
 
 
 Object.assign(u, { arrayBufferToHex, arrayBufferToString, stringToArrayBuffer, arrayBufferToBase64, base64ToArrayBuffer });
 Object.assign(u, { arrayBufferToHex, arrayBufferToString, stringToArrayBuffer, arrayBufferToBase64, base64ToArrayBuffer });

+ 1 - 1
src/plugins/chatview/tests/http-file-upload.js

@@ -280,7 +280,7 @@ describe("XEP-0363: HTTP File Upload", function () {
 
 
                     expect(view.querySelector('.chat-msg .chat-msg__media').innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
                     expect(view.querySelector('.chat-msg .chat-msg__media').innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
                         `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
                         `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                        `Download image file "conversejs-filled.svg"</a>`);
+                        `Download file "conversejs-filled.svg"</a>`);
                     XMLHttpRequest.prototype.send = send_backup;
                     XMLHttpRequest.prototype.send = send_backup;
                     done();
                     done();
                 }));
                 }));

+ 1 - 1
src/plugins/chatview/tests/oob.js

@@ -162,7 +162,7 @@ describe("A Chat Message", function () {
             const media = view.querySelector('.chat-msg .chat-msg__media');
             const media = view.querySelector('.chat-msg .chat-msg__media');
             expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "")).toEqual(
             expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "")).toEqual(
                 `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
                 `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                `Download image file "conversejs-filled.svg"</a>`);
+                `Download file "conversejs-filled.svg"</a>`);
             done();
             done();
         }));
         }));
     });
     });

+ 1 - 1
src/plugins/muc-views/tests/http-file-upload.js

@@ -145,7 +145,7 @@ describe("XEP-0363: HTTP File Upload", function () {
 
 
                     expect(view.querySelector('.chat-msg .chat-msg__media').innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
                     expect(view.querySelector('.chat-msg .chat-msg__media').innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
                         `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
                         `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
-                        `Download image file "conversejs-filled.svg"</a>`);
+                        `Download file "conversejs-filled.svg"</a>`);
 
 
                     XMLHttpRequest.prototype.send = send_backup;
                     XMLHttpRequest.prototype.send = send_backup;
                     done();
                     done();

+ 3 - 0
src/plugins/omemo/index.js

@@ -20,6 +20,7 @@ import { OMEMOEnabledChatBox } from './mixins/chatbox.js';
 import { _converse, api, converse } from '@converse/headless/core';
 import { _converse, api, converse } from '@converse/headless/core';
 import {
 import {
     getOMEMOToolbarButton,
     getOMEMOToolbarButton,
+    handleEncryptedFiles,
     initOMEMO,
     initOMEMO,
     omemo,
     omemo,
     onChatBoxesInitialized,
     onChatBoxesInitialized,
@@ -83,6 +84,8 @@ converse.plugins.add('converse-omemo', {
         api.listen.on('statusInitialized', initOMEMO);
         api.listen.on('statusInitialized', initOMEMO);
         api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
         api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
 
 
+        api.listen.on('afterMessageBodyTransformed', handleEncryptedFiles);
+
         api.listen.on('userDetailsModalInitialized', contact => {
         api.listen.on('userDetailsModalInitialized', contact => {
             const jid = contact.get('jid');
             const jid = contact.get('jid');
             _converse.generateFingerprints(jid).catch(e => log.error(e));
             _converse.generateFingerprints(jid).catch(e => log.error(e));

+ 113 - 0
src/plugins/omemo/utils.js

@@ -1,16 +1,25 @@
 /* global libsignal */
 /* global libsignal */
+import URI from 'urijs';
 import difference from 'lodash-es/difference';
 import difference from 'lodash-es/difference';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
+import tpl_audio from 'templates/audio.js';
+import tpl_file from 'templates/file.js';
+import tpl_image from 'templates/image.js';
+import tpl_video from 'templates/video.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { _converse, converse, api } from '@converse/headless/core';
 import { _converse, converse, api } from '@converse/headless/core';
 import { html } from 'lit';
 import { html } from 'lit';
 import { initStorage } from '@converse/headless/shared/utils.js';
 import { initStorage } from '@converse/headless/shared/utils.js';
+import { isAudioURL, isImageURL, isVideoURL, getURI } from 'utils/html.js';
+import { until } from 'lit/directives/until.js';
+import { MIMETYPES_MAP } from 'utils/file.js';
 import {
 import {
     appendArrayBuffer,
     appendArrayBuffer,
     arrayBufferToBase64,
     arrayBufferToBase64,
     arrayBufferToHex,
     arrayBufferToHex,
     arrayBufferToString,
     arrayBufferToString,
     base64ToArrayBuffer,
     base64ToArrayBuffer,
+    hexToArrayBuffer,
     stringToArrayBuffer
     stringToArrayBuffer
 } from '@converse/headless/utils/arraybuffer.js';
 } from '@converse/headless/utils/arraybuffer.js';
 
 
@@ -68,6 +77,110 @@ export const omemo = {
     }
     }
 }
 }
 
 
+async function decryptFile (iv, key, cipher) {
+    const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
+    const algo = {
+        'name': 'AES-GCM',
+        'iv': hexToArrayBuffer(iv),
+    };
+    return crypto.subtle.decrypt(algo, key_obj, cipher);
+}
+
+async function downloadFile(url) {
+    let response;
+    try {
+        response = await fetch(url)
+    } catch(e) {
+        log.error(`Failed to download encrypted media: ${url}`);
+        log.error(e);
+        return null;
+    }
+
+    if (response.status >= 200 && response.status < 400) {
+        return response.arrayBuffer();
+    }
+}
+
+async function getAndDecryptFile (uri) {
+    const hash = uri.hash().slice(1);
+    const protocol = window.location.hostname === 'localhost' ? 'http' : 'https';
+    const http_url = uri.toString().replace(/^aesgcm/, protocol);
+    const cipher = await downloadFile(http_url);
+    const iv = hash.slice(0, 24);
+    const key = hash.slice(24);
+    let content;
+    try {
+        content = await decryptFile(iv, key, cipher);
+    } catch (e) {
+        log.error(`Could not decrypt file ${uri.toString()}`);
+        log.error(e);
+        return null;
+    }
+    const [filename, extension] = uri.filename()?.split('.');
+    const mimetype = MIMETYPES_MAP[extension];
+    try {
+        const file = new File([content], filename, { 'type': mimetype });
+        return URL.createObjectURL(file);
+    } catch (e) {
+        log.error(`Could not decrypt file ${uri.toString()}`);
+        log.error(e);
+        return null;
+    }
+}
+
+function getTemplateForObjectURL (uri, obj_url, richtext) {
+    const file_url = uri.toString();
+    if (obj_url === null) {
+        return file_url;
+    }
+    if (isImageURL(file_url)) {
+        return tpl_image({
+            'url': obj_url,
+            'onClick': richtext.onImgClick,
+            'onLoad': richtext.onImgLoad
+        });
+    } else if (isAudioURL(file_url)) {
+        return tpl_audio(obj_url);
+    } else if (isVideoURL(file_url)) {
+        return tpl_video(obj_url);
+    } else {
+        return tpl_file(obj_url, uri.filename());
+    }
+
+}
+
+function addEncryptedFiles(text, offset, richtext) {
+    const objs = [];
+    try {
+        const parse_options = { 'start': /\b(aesgcm:\/\/)/gi };
+        URI.withinString(
+            text,
+            (url, start, end) => {
+                objs.push({ url, start, end });
+                return url;
+            },
+            parse_options
+        );
+    } catch (error) {
+        log.debug(error);
+        return;
+    }
+    objs.forEach(o => {
+        const uri = getURI(text.slice(o.start, o.end));
+        const promise = getAndDecryptFile(uri)
+            .then(obj_url => getTemplateForObjectURL(uri, obj_url, richtext));
+        const template = html`${until(promise, '')}`;
+        richtext.addTemplateResult(o.start + offset, o.end + offset, template);
+    });
+}
+
+export function handleEncryptedFiles (richtext) {
+    if (!_converse.config.get('trusted')) {
+        return;
+    }
+    richtext.addAnnotations((text, offset) => addEncryptedFiles(text, offset, richtext));
+}
+
 export function parseEncryptedMessage (stanza, attrs) {
 export function parseEncryptedMessage (stanza, attrs) {
     if (attrs.is_encrypted && attrs.encrypted.key) {
     if (attrs.is_encrypted && attrs.encrypted.key) {
         // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
         // https://xmpp.org/extensions/xep-0384.html#usecases-receiving

+ 4 - 3
src/shared/rich-text.js

@@ -16,11 +16,12 @@ import {
     getHyperlinkTemplate,
     getHyperlinkTemplate,
     isAudioDomainAllowed,
     isAudioDomainAllowed,
     isAudioURL,
     isAudioURL,
+    isEncryptedFileURL,
     isImageDomainAllowed,
     isImageDomainAllowed,
     isImageURL,
     isImageURL,
     isVideoDomainAllowed,
     isVideoDomainAllowed,
     isVideoURL
     isVideoURL
-} from 'utils/html';
+} from 'utils/html.js';
 import { html } from 'lit';
 import { html } from 'lit';
 
 
 const isString = s => typeof s === 'string';
 const isString = s => typeof s === 'string';
@@ -105,10 +106,10 @@ export class RichText extends String {
             log.debug(error);
             log.debug(error);
             return;
             return;
         }
         }
-        objs.forEach(url_obj => {
+
+        objs.filter(o => !isEncryptedFileURL(text.slice(o.start, o.end))).forEach(url_obj => {
             const url_text = text.slice(url_obj.start, url_obj.end);
             const url_text = text.slice(url_obj.start, url_obj.end);
             const filtered_url = filterQueryParamsFromURL(url_text);
             const filtered_url = filterQueryParamsFromURL(url_text);
-
             let template;
             let template;
             if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) {
             if (this.show_images && isImageURL(url_text) && isImageDomainAllowed(url_text)) {
                 template = tpl_image({
                 template = tpl_image({

+ 5 - 1
src/templates/file.js

@@ -1,3 +1,7 @@
+import { __ } from 'i18n';
 import { html } from "lit";
 import { html } from "lit";
 
 
-export default (o) => html`<a target="_blank" rel="noopener" href="${o.url}">${o.label_download}</a>`;
+export default (url, name) => {
+    const i18n_download =  __('Download file "%1$s"', name)
+    return html`<a target="_blank" rel="noopener" href="${url}">${i18n_download}</a>`;
+}

+ 79 - 0
src/utils/file.js

@@ -0,0 +1,79 @@
+export const MIMETYPES_MAP = {
+  'aac': 'audio/aac',
+  'abw': 'application/x-abiword',
+  'arc': 'application/x-freearc',
+  'avi': 'video/x-msvideo',
+  'azw': 'application/vnd.amazon.ebook',
+  'bin': 'application/octet-stream',
+  'bmp': 'image/bmp',
+  'bz': 'application/x-bzip',
+  'bz2': 'application/x-bzip2',
+  'cda': 'application/x-cdf',
+  'csh': 'application/x-csh',
+  'css': 'text/css',
+  'csv': 'text/csv',
+  'doc': 'application/msword',
+  'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  'eot': 'application/vnd.ms-fontobject',
+  'epub': 'application/epub+zip',
+  'gif': 'image/gif',
+  'gz': 'application/gzip',
+  'htm': 'text/html',
+  'html': 'text/html',
+  'ico': 'image/vnd.microsoft.icon',
+  'ics': 'text/calendar',
+  'jar': 'application/java-archive',
+  'jpeg': 'image/jpeg',
+  'jpg': 'image/jpeg',
+  'js': 'text/javascript',
+  'json': 'application/json',
+  'jsonld': 'application/ld+json',
+  'm4a': 'audio/mp4',
+  'mid': 'audio/midi',
+  'midi': 'audio/midi',
+  'mjs': 'text/javascript',
+  'mp3': 'audio/mpeg',
+  'mp4': 'video/mp4',
+  'mpeg': 'video/mpeg',
+  'mpkg': 'application/vnd.apple.installer+xml',
+  'odp': 'application/vnd.oasis.opendocument.presentation',
+  'ods': 'application/vnd.oasis.opendocument.spreadsheet',
+  'odt': 'application/vnd.oasis.opendocument.text',
+  'oga': 'audio/ogg',
+  'ogv': 'video/ogg',
+  'ogx': 'application/ogg',
+  'opus': 'audio/opus',
+  'otf': 'font/otf',
+  'png': 'image/png',
+  'pdf': 'application/pdf',
+  'php': 'application/x-httpd-php',
+  'ppt': 'application/vnd.ms-powerpoint',
+  'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+  'rar': 'application/vnd.rar',
+  'rtf': 'application/rtf',
+  'sh': 'application/x-sh',
+  'svg': 'image/svg+xml',
+  'swf': 'application/x-shockwave-flash',
+  'tar': 'application/x-tar',
+  'tif': 'image/tiff',
+  'tiff': 'image/tiff',
+  'ts': 'video/mp2t',
+  'ttf': 'font/ttf',
+  'txt': 'text/plain',
+  'vsd': 'application/vnd.visio',
+  'wav': 'audio/wav',
+  'weba': 'audio/webm',
+  'webm': 'video/webm',
+  'webp': 'image/webp',
+  'woff': 'font/woff',
+  'woff2': 'font/woff2',
+  'xhtml': 'application/xhtml+xml',
+  'xls': 'application/vnd.ms-excel',
+  'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  'xml': 'text/xml',
+  'xul': 'application/vnd.mozilla.xul+xml',
+  'zip': 'application/zip',
+  '3gp': 'video/3gpp',
+  '3g2': 'video/3gpp2',
+  '7z': 'application/x-7z-compressed'
+}

+ 9 - 25
src/utils/html.js

@@ -7,7 +7,6 @@ import URI from 'urijs';
 import isFunction from 'lodash-es/isFunction';
 import isFunction from 'lodash-es/isFunction';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
 import tpl_audio from 'templates/audio.js';
 import tpl_audio from 'templates/audio.js';
-import tpl_video from 'templates/video.js';
 import tpl_file from 'templates/file.js';
 import tpl_file from 'templates/file.js';
 import tpl_form_captcha from '../templates/form_captcha.js';
 import tpl_form_captcha from '../templates/form_captcha.js';
 import tpl_form_checkbox from '../templates/form_checkbox.js';
 import tpl_form_checkbox from '../templates/form_checkbox.js';
@@ -18,6 +17,7 @@ import tpl_form_textarea from '../templates/form_textarea.js';
 import tpl_form_url from '../templates/form_url.js';
 import tpl_form_url from '../templates/form_url.js';
 import tpl_form_username from '../templates/form_username.js';
 import tpl_form_username from '../templates/form_username.js';
 import tpl_hyperlink from 'templates/hyperlink.js';
 import tpl_hyperlink from 'templates/hyperlink.js';
+import tpl_video from 'templates/video.js';
 import u from '../headless/utils/core';
 import u from '../headless/utils/core';
 import { api, converse } from '@converse/headless/core';
 import { api, converse } from '@converse/headless/core';
 import { render } from 'lit';
 import { render } from 'lit';
@@ -89,6 +89,10 @@ export function isVideoURL (url) {
     return checkFileTypes(['.mp4', '.webm'], url);
     return checkFileTypes(['.mp4', '.webm'], url);
 }
 }
 
 
+export function isEncryptedFileURL (url) {
+    return url.startsWith('aesgcm://');
+}
+
 export function isImageURL (url) {
 export function isImageURL (url) {
     const regex = api.settings.get('image_urls_regex');
     const regex = api.settings.get('image_urls_regex');
     return regex?.test(url) || isURLWithImageExtension(url);
     return regex?.test(url) || isURLWithImageExtension(url);
@@ -141,7 +145,7 @@ export function isImageDomainAllowed (url) {
     }
     }
 }
 }
 
 
-export function getFileName (uri) {
+function getFileName (uri) {
     try {
     try {
         return decodeURI(uri.filename());
         return decodeURI(uri.filename());
     } catch (error) {
     } catch (error) {
@@ -150,26 +154,6 @@ export function getFileName (uri) {
     }
     }
 }
 }
 
 
-function renderAudioURL (url) {
-    return tpl_audio(url);
-}
-
-function renderImageURL (_converse, uri) {
-    const { __ } = _converse;
-    return tpl_file({
-        'url': uri.toString(),
-        'label_download': __('Download image file "%1$s"', getFileName(uri))
-    });
-}
-
-function renderFileURL (_converse, uri) {
-    const { __ } = _converse;
-    return tpl_file({
-        'url': uri.toString(),
-        'label_download': __('Download file "%1$s"', getFileName(uri))
-    });
-}
-
 /**
 /**
  * Returns the markup for a URL that points to a downloadable asset
  * Returns the markup for a URL that points to a downloadable asset
  * (such as a video, image or audio file).
  * (such as a video, image or audio file).
@@ -185,11 +169,11 @@ u.getOOBURLMarkup = function (_converse, url) {
     if (u.isVideoURL(uri)) {
     if (u.isVideoURL(uri)) {
         return tpl_video(url);
         return tpl_video(url);
     } else if (u.isAudioURL(uri)) {
     } else if (u.isAudioURL(uri)) {
-        return renderAudioURL(url);
+        return tpl_audio(url);
     } else if (u.isImageURL(uri)) {
     } else if (u.isImageURL(uri)) {
-        return renderImageURL(_converse, uri);
+        return tpl_file(uri.toString(), getFileName(uri));
     } else {
     } else {
-        return renderFileURL(_converse, uri);
+        return tpl_file(uri.toString(), getFileName(uri));
     }
     }
 };
 };