From 5c6c5c2b8cacf983e7e1292046d28fab30fe9fc8 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 25 Mar 2021 10:58:21 +1100 Subject: [PATCH] First commit encrypting attachments locally --- app/attachments.js | 16 +- js/modules/attachment_downloads.js | 12 -- js/modules/types/visual_attachment.js | 31 +++- ts/components/conversation/Image.tsx | 244 +++++++++++++------------- ts/hooks/to-arraybuffer.d.ts | 1 + ts/hooks/useEncryptedFileFetch.ts | 29 +++ ts/types/Attachment.ts | 88 +++++++++- 7 files changed, 283 insertions(+), 138 deletions(-) create mode 100644 ts/hooks/to-arraybuffer.d.ts create mode 100644 ts/hooks/useEncryptedFileFetch.ts diff --git a/app/attachments.js b/app/attachments.js index f8b8627fe..fe18e7cdc 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -6,6 +6,7 @@ const glob = require('glob'); const fse = require('fs-extra'); const toArrayBuffer = require('to-arraybuffer'); const { map, isArrayBuffer, isString } = require('lodash'); +const AttachmentTS = require('../ts/types/Attachment'); const PATH = 'attachments.noindex'; @@ -45,14 +46,18 @@ exports.createReader = root => { if (!isString(relativePath)) { throw new TypeError("'relativePath' must be a string"); } - + console.time(`readFile: ${relativePath}`); const absolutePath = path.join(root, relativePath); const normalized = path.normalize(absolutePath); if (!normalized.startsWith(root)) { throw new Error('Invalid relative path'); } const buffer = await fse.readFile(normalized); - return toArrayBuffer(buffer); + const decryptedData = await AttachmentTS.decryptAttachmentBuffer( + toArrayBuffer(buffer) + ); + + return decryptedData.buffer; }; }; @@ -95,7 +100,6 @@ exports.createWriterForExisting = root => { throw new TypeError("'arrayBuffer' must be an array buffer"); } - const buffer = Buffer.from(arrayBuffer); const absolutePath = path.join(root, relativePath); const normalized = path.normalize(absolutePath); if (!normalized.startsWith(root)) { @@ -103,7 +107,13 @@ exports.createWriterForExisting = root => { } await fse.ensureFile(normalized); + const { + encryptedBufferWithHeader, + } = await AttachmentTS.encryptAttachmentBuffer(arrayBuffer); + const buffer = Buffer.from(encryptedBufferWithHeader.buffer); + await fse.writeFile(normalized, buffer); + return relativePath; }; }; diff --git a/js/modules/attachment_downloads.js b/js/modules/attachment_downloads.js index 802c3c767..dfd79694a 100644 --- a/js/modules/attachment_downloads.js +++ b/js/modules/attachment_downloads.js @@ -254,18 +254,6 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) { const logPrefix = `${message.idForLogging()} (type: ${type}, index: ${index})`; - if (type === 'long-message') { - try { - const { data } = await Signal.Migrations.loadAttachmentData(attachment); - message.set({ - body: attachment.isError ? message.get('body') : stringFromBytes(data), - }); - } finally { - Signal.Migrations.deleteAttachmentData(attachment.path); - } - return; - } - if (type === 'attachment') { const attachments = message.get('attachments'); if (!attachments || attachments.length <= index) { diff --git a/js/modules/types/visual_attachment.js b/js/modules/types/visual_attachment.js index 254e2a021..cd46030d8 100644 --- a/js/modules/types/visual_attachment.js +++ b/js/modules/types/visual_attachment.js @@ -1,12 +1,18 @@ +/* eslint-disable more/no-then */ /* global document, URL, Blob */ const loadImage = require('blueimp-load-image'); const { toLogFormat } = require('./errors'); +const toArrayBuffer = require('to-arraybuffer'); + const dataURLToBlobSync = require('blueimp-canvas-to-blob'); +const fse = require('fs-extra'); + const { blobToArrayBuffer } = require('blob-util'); const { arrayBufferToObjectURL, } = require('../../../ts/util/arrayBufferToObjectURL'); +const AttachmentTS = require('../../../ts/types/Attachment'); exports.blobToArrayBuffer = blobToArrayBuffer; @@ -24,8 +30,17 @@ exports.getImageDimensions = ({ objectUrl, logger }) => logger.error('getImageDimensions error', toLogFormat(error)); reject(error); }); - - image.src = objectUrl; + fse.readFile(objectUrl).then(buffer => { + AttachmentTS.decryptAttachmentBuffer(toArrayBuffer(buffer)).then( + decryptedData => { + //FIXME image/jpeg is hard coded + const srcData = `data:image/jpg;base64,${window.libsession.Utils.StringUtils.fromArrayBufferToBase64( + toArrayBuffer(decryptedData) + )}`; + image.src = srcData; + } + ); + }); }); exports.makeImageThumbnail = ({ @@ -70,7 +85,17 @@ exports.makeImageThumbnail = ({ reject(error); }); - image.src = objectUrl; + fse.readFile(objectUrl).then(buffer => { + AttachmentTS.decryptAttachmentBuffer(toArrayBuffer(buffer)).then( + decryptedData => { + //FIXME image/jpeg is hard coded + const srcData = `data:image/jpg;base64,${window.libsession.Utils.StringUtils.fromArrayBufferToBase64( + toArrayBuffer(decryptedData) + )}`; + image.src = srcData; + } + ); + }); }); exports.makeVideoScreenshot = ({ diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index d2cf67eab..c65e6c7d1 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -4,8 +4,11 @@ import classNames from 'classnames'; import { Spinner } from '../Spinner'; import { LocalizerType } from '../../types/Util'; import { AttachmentType } from '../../types/Attachment'; +import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; +import { fromArrayBufferToBase64 } from '../../session/utils/String'; +import toArrayBuffer from 'to-arraybuffer'; -interface Props { +type Props = { alt: string; attachment: AttachmentType; url: string; @@ -32,135 +35,140 @@ interface Props { onClick?: (attachment: AttachmentType) => void; onClickClose?: (attachment: AttachmentType) => void; onError?: () => void; -} +}; -export class Image extends React.Component { +export const Image = (props: Props) => { // tslint:disable-next-line max-func-body-length cyclomatic-complexity - public render() { - const { - alt, - attachment, - bottomOverlay, - closeButton, - curveBottomLeft, - curveBottomRight, - curveTopLeft, - curveTopRight, - darkOverlay, - height, - i18n, - onClick, - onClickClose, - onError, - overlayText, - playIconOverlay, - smallCurveTopLeft, - softCorners, - url, - width, - } = this.props; + const { + alt, + attachment, + bottomOverlay, + closeButton, + curveBottomLeft, + curveBottomRight, + curveTopLeft, + curveTopRight, + darkOverlay, + height, + i18n, + onClick, + onClickClose, + onError, + overlayText, + playIconOverlay, + smallCurveTopLeft, + softCorners, + url, + width, + } = props; - const { caption, pending } = attachment || { caption: null, pending: true }; - const canClick = onClick && !pending; - const role = canClick ? 'button' : undefined; + const { caption, pending } = attachment || { caption: null, pending: true }; + const canClick = onClick && !pending; + const role = canClick ? 'button' : undefined; - return ( + const { loading, data } = useEncryptedFileFetch(url); + //FIXME jpg is hardcoded + const srcData = !loading + ? data?.length + ? `data:image/jpg;base64,${fromArrayBufferToBase64(toArrayBuffer(data))}` + : url + : ''; + + return ( +
{ + if (canClick && onClick) { + e.stopPropagation(); + onClick(attachment); + } + }} + className={classNames( + 'module-image', + canClick ? 'module-image__with-click-handler' : null, + curveBottomLeft ? 'module-image--curved-bottom-left' : null, + curveBottomRight ? 'module-image--curved-bottom-right' : null, + curveTopLeft ? 'module-image--curved-top-left' : null, + curveTopRight ? 'module-image--curved-top-right' : null, + smallCurveTopLeft ? 'module-image--small-curved-top-left' : null, + softCorners ? 'module-image--soft-corners' : null + )} + > + {pending || loading ? ( +
+ +
+ ) : ( + {alt} + )} + {caption ? ( + {i18n('imageCaptionIconAlt')} + ) : null}
{ - if (canClick && onClick) { - e.stopPropagation(); - onClick(attachment); - } - }} className={classNames( - 'module-image', - canClick ? 'module-image__with-click-handler' : null, - curveBottomLeft ? 'module-image--curved-bottom-left' : null, - curveBottomRight ? 'module-image--curved-bottom-right' : null, + 'module-image__border-overlay', curveTopLeft ? 'module-image--curved-top-left' : null, curveTopRight ? 'module-image--curved-top-right' : null, + curveBottomLeft ? 'module-image--curved-bottom-left' : null, + curveBottomRight ? 'module-image--curved-bottom-right' : null, smallCurveTopLeft ? 'module-image--small-curved-top-left' : null, - softCorners ? 'module-image--soft-corners' : null + softCorners ? 'module-image--soft-corners' : null, + darkOverlay ? 'module-image__border-overlay--dark' : null )} - > - {pending ? ( -
- -
- ) : ( - {alt} - )} - {caption ? ( - {i18n('imageCaptionIconAlt')} - ) : null} + /> + {closeButton ? ( +
{ + e.stopPropagation(); + if (onClickClose) { + onClickClose(attachment); + } + }} + className="module-image__close-button" + /> + ) : null} + {bottomOverlay ? (
- {closeButton ? ( -
{ - e.stopPropagation(); - if (onClickClose) { - onClickClose(attachment); - } - }} - className="module-image__close-button" - /> - ) : null} - {bottomOverlay ? ( -
- ) : null} - {!pending && playIconOverlay ? ( -
-
-
- ) : null} - {overlayText ? ( -
- {overlayText} -
- ) : null} -
- ); - } -} + ) : null} + {!(pending || loading) && playIconOverlay ? ( +
+
+
+ ) : null} + {overlayText ? ( +
+ {overlayText} +
+ ) : null} +
+ ); +}; diff --git a/ts/hooks/to-arraybuffer.d.ts b/ts/hooks/to-arraybuffer.d.ts new file mode 100644 index 000000000..a8f604fc1 --- /dev/null +++ b/ts/hooks/to-arraybuffer.d.ts @@ -0,0 +1 @@ +declare module 'to-arraybuffer'; diff --git a/ts/hooks/useEncryptedFileFetch.ts b/ts/hooks/useEncryptedFileFetch.ts new file mode 100644 index 000000000..fa5630b70 --- /dev/null +++ b/ts/hooks/useEncryptedFileFetch.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; +import toArrayBuffer from 'to-arraybuffer'; +import * as fse from 'fs-extra'; +import { decryptAttachmentBuffer } from '../types/Attachment'; + +export const useEncryptedFileFetch = (url: string) => { + // tslint:disable-next-line: no-bitwise + const [data, setData] = useState(new Uint8Array()); + const [loading, setLoading] = useState(true); + + async function fetchUrl() { + // this is a file encoded by session + //FIXME find another way to know if the file in encrypted or not + // maybe rely on + if (url.includes('/attachments.noindex/')) { + const encryptedFileContent = await fse.readFile(url); + const decryptedContent = await decryptAttachmentBuffer( + toArrayBuffer(encryptedFileContent) + ); + setData(decryptedContent); + } + setLoading(false); + } + + useEffect(() => { + void fetchUrl(); + }, [url]); + return { data, loading }; +}; diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 9a2b1cd32..56c22c533 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -1,9 +1,8 @@ import is from '@sindresorhus/is'; import moment from 'moment'; -import { padStart } from 'lodash'; +import { isArrayBuffer, padStart } from 'lodash'; import * as MIME from './MIME'; -import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL'; import { saveURLAsFile } from '../util/saveURLAsFile'; import { SignalService } from '../protobuf'; import { @@ -11,6 +10,9 @@ import { isVideoTypeSupported, } from '../util/GoogleChrome'; import { LocalizerType } from './Util'; +import { fromHexToArray, toHex } from '../session/utils/String'; +import { getSodium } from '../session/crypto'; +import { fromHex } from 'bytebuffer'; const MAX_WIDTH = 300; const MAX_HEIGHT = MAX_WIDTH * 1.5; @@ -416,3 +418,85 @@ export const getFileExtension = ( return attachment.contentType.split('/')[1]; } }; +let indexEncrypt = 0; + +export const encryptAttachmentBuffer = async (bufferIn: ArrayBuffer) => { + if (!isArrayBuffer(bufferIn)) { + throw new TypeError("'bufferIn' must be an array buffer"); + } + const ourIndex = indexEncrypt; + indexEncrypt++; + console.time(`timer #*. encryptAttachmentBuffer ${ourIndex}`); + + const uintArrayIn = new Uint8Array(bufferIn); + const sodium = await getSodium(); + + /* Shared secret key required to encrypt/decrypt the stream */ + // const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + + const key = fromHexToArray( + '0c5f7147b6d3239cbb5a418814cee1bfca2df5c94bffddf22ee37eea3ede972b' + ); + console.warn('key', toHex(key)); + + /* Set up a new stream: initialize the state and create the header */ + const { + state, + header, + } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key); + console.warn('header', toHex(header)); + /* Now, encrypt the buffer. */ + const bufferOut = sodium.crypto_secretstream_xchacha20poly1305_push( + state, + uintArrayIn, + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL + ); + + const encryptedBufferWithHeader = new Uint8Array( + bufferOut.length + header.length + ); + encryptedBufferWithHeader.set(header); + encryptedBufferWithHeader.set(bufferOut, header.length); + console.warn('bufferOut', toHex(encryptedBufferWithHeader)); + console.timeEnd(`timer #*. encryptAttachmentBuffer ${ourIndex}`); + + return { encryptedBufferWithHeader, header, key }; +}; + +let indexDecrypt = 0; + +export const decryptAttachmentBuffer = async ( + bufferIn: ArrayBuffer, + key: string = '0c5f7147b6d3239cbb5a418814cee1bfca2df5c94bffddf22ee37eea3ede972b' +) => { + if (!isArrayBuffer(bufferIn)) { + throw new TypeError("'bufferIn' must be an array buffer"); + } + const ourIndex = indexDecrypt; + indexDecrypt++; + console.time(`timer .*# decryptAttachmentBuffer ${ourIndex}`); + const sodium = await getSodium(); + + const header = new Uint8Array( + bufferIn.slice(0, sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES) + ); + const encryptedBuffer = new Uint8Array( + bufferIn.slice(sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES) + ); + + /* Decrypt the stream: initializes the state, using the key and a header */ + const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull( + header, + fromHexToArray(key) + ); + // what if ^ this call fail (? try to load as a unencrypted attachment?) + + const messageTag = sodium.crypto_secretstream_xchacha20poly1305_pull( + state, + encryptedBuffer + ); + console.timeEnd(`timer .*# decryptAttachmentBuffer ${ourIndex}`); + + return messageTag.message; +};