From 2cfffa015d08ea78da979c1a0a2342e89e14c8e6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 22:36:26 +0100 Subject: [PATCH] WIP --- prototypes/ie11-textdecoder.html | 23 ++++++++ src/domain/session/room/RoomViewModel.js | 30 +++++++++++ .../session/room/timeline/tiles/FileTile.js | 8 +-- .../session/room/timeline/tiles/ImageTile.js | 6 +-- src/matrix/e2ee/attachment.js | 18 +++++++ src/matrix/net/MediaRepository.js | 10 +++- src/platform/web/Platform.js | 34 +++++++++--- .../dom/{BufferHandle.js => BlobHandle.js} | 54 +++++++++++++++++-- src/platform/web/dom/Crypto.js | 28 ++++++++-- 9 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 prototypes/ie11-textdecoder.html rename src/platform/web/dom/{BufferHandle.js => BlobHandle.js} (69%) diff --git a/prototypes/ie11-textdecoder.html b/prototypes/ie11-textdecoder.html new file mode 100644 index 00000000..fbd02475 --- /dev/null +++ b/prototypes/ie11-textdecoder.html @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index e2a092ae..3b447c60 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -164,6 +164,36 @@ export class RoomViewModel extends ViewModel { return false; } + async _sendFile() { + const file = this.platform.openFile(); + let blob = file.blob; + let encryptedFile; + if (this._room.isEncrypted) { + const {data, info} = await this._room.encryptAttachment(blob); + blob = data; + encryptedFile = Object.assign(info, { + mimetype: file.blob.mimeType, + url: null + }); + } + const mxcUrl = await this._room.mediaRepository.upload(blob, file.name); + const content = { + body: file.name, + msgtype: "m.file", + info: { + size: blob.size, + mimetype: file.blob.mimeType, + }, + }; + if (encryptedFile) { + encryptedFile.url = mxcUrl; + content.file = encryptedFile; + } else { + content.url = mxcUrl; + } + await this._room.sendEvent("m.room.message", content); + } + get composerViewModel() { return this._composerVM; } diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index b2afd728..1900c20a 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -33,14 +33,14 @@ export class FileTile extends MessageTile { const filename = content.body; this._downloading = true; this.emitChange("label"); - let bufferHandle; + let blob; try { - bufferHandle = await this._mediaRepository.downloadAttachment(content); - this.platform.offerSaveBufferHandle(bufferHandle, filename); + blob = await this._mediaRepository.downloadAttachment(content); + this.platform.saveFileAs(blob, filename); } catch (err) { this._error = err; } finally { - bufferHandle?.dispose(); + blob?.dispose(); this._downloading = false; } this.emitChange("label"); diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 71b5b9d6..6d518b11 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -35,12 +35,12 @@ export class ImageTile extends MessageTile { } async _loadEncryptedFile(file) { - const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file, true); + const blob = await this._mediaRepository.downloadEncryptedFile(file, true); if (this.isDisposed) { - bufferHandle.dispose(); + blob.dispose(); return; } - return this.track(bufferHandle); + return this.track(blob); } async load() { diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index 408e04fe..cea91cfa 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -56,3 +56,21 @@ export async function decryptAttachment(crypto, ciphertextBuffer, info) { }); return decryptedBuffer; } + +export async function encryptAttachment(crypto, data) { + const iv = await crypto.aes.generateIV(); + const key = await crypto.aes.generateKey("jwk", 256); + const ciphertext = await crypto.aes.encryptCTR({key, iv, data}); + const digest = await crypto.digest("SHA-256", ciphertext); + return { + data: ciphertext, + info: { + v: "v2", + key, + iv: base64.encode(iv), + hashes: { + sha256: base64.encode(digest) + } + } + }; +} diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index f04c387f..8ab37a87 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -56,13 +56,13 @@ export class MediaRepository { const url = this.mxcUrl(fileEntry.url); const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response(); const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry); - return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype); + return this._platform.createBlob(decryptedBuffer, fileEntry.mimetype); } async downloadPlaintextFile(mxcUrl, mimetype, cache = false) { const url = this.mxcUrl(mxcUrl); const {body: buffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response(); - return this._platform.createBufferHandle(buffer, mimetype); + return this._platform.createBlob(buffer, mimetype); } async downloadAttachment(content, cache = false) { @@ -73,4 +73,10 @@ export class MediaRepository { } } + async upload(bufferHandle, filename) { + const url = `${this._homeServer}/_matrix/media/r0/upload?filename=${encodeURIComponent(filename)}`; + // TODO: body doesn't take a bufferHandle currently + const {content_uri} = await this._platform.request(url, {method: "POST", body: bufferHandle}).response(); + return content_uri; + } } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 745122d9..66373844 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js"; import {Crypto} from "./dom/Crypto.js"; import {estimateStorageUsage} from "./dom/StorageEstimate.js"; import {WorkerPool} from "./dom/WorkerPool.js"; -import {BufferHandle} from "./dom/BufferHandle.js"; +import {BlobHandle} from "./dom/BlobHandle.js"; import {downloadInIframe} from "./dom/download.js"; function addScript(src) { @@ -131,15 +131,37 @@ export class Platform { this._serviceWorkerHandler?.setNavigation(navigation); } - createBufferHandle(buffer, mimetype) { - return new BufferHandle(buffer, mimetype); + createBlob(buffer, mimetype) { + return BlobHandle.fromBuffer(buffer, mimetype); } - offerSaveBufferHandle(bufferHandle, filename) { + saveFileAs(blobHandle, filename) { if (navigator.msSaveBlob) { - navigator.msSaveBlob(bufferHandle.blob, filename); + navigator.msSaveBlob(blobHandle.blob, filename); } else { - downloadInIframe(this._container, this._paths.downloadSandbox, bufferHandle.blob, filename); + downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle.blob, filename); } } + + openFile(mimeType = null) { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + if (mimeType) { + input.setAttribute("accept", mimeType); + } + const promise = new Promise((resolve, reject) => { + const checkFile = () => { + input.removeEventListener("change", checkFile, true); + const file = input.files[0]; + if (file) { + resolve({name: file.name, blob: BlobHandle.fromFile(file)}); + } else { + reject(new Error("No file selected")); + } + } + input.addEventListener("change", checkFile, true); + }); + input.click(); + return promise; + } } diff --git a/src/platform/web/dom/BufferHandle.js b/src/platform/web/dom/BlobHandle.js similarity index 69% rename from src/platform/web/dom/BufferHandle.js rename to src/platform/web/dom/BlobHandle.js index 80bb40bb..2da3a776 100644 --- a/src/platform/web/dom/BufferHandle.js +++ b/src/platform/web/dom/BlobHandle.js @@ -69,14 +69,52 @@ const ALLOWED_BLOB_MIMETYPES = { 'audio/x-flac': true, }; -export class BufferHandle { - constructor(buffer, mimetype) { +export class BlobHandle { + constructor(blob, buffer = null) { + this.blob = blob; + this._buffer = buffer; + this._url = null; + } + + static fromBuffer(buffer, mimetype) { mimetype = mimetype ? mimetype.split(";")[0].trim() : ''; if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { mimetype = 'application/octet-stream'; } - this.blob = new Blob([buffer], {type: mimetype}); - this._url = null; + return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer); + } + + static fromFile(file) { + // ok to not filter mimetypes as these are local files + return new BlobHandle(file); + } + + async readAsBuffer() { + if (this._buffer) { + return this._buffer; + } else { + const reader = new FileReader(); + const promise = new Promise((resolve, reject) => { + reader.addEventListener("load", evt => resolve(evt.target.result)); + reader.addEventListener("error", evt => reject(evt.target.error)); + }); + reader.readAsArrayBuffer(this.blob); + return promise; + } + } + + async readAsText() { + if (this._buffer) { + return this._buffer; + } else { + const reader = new FileReader(); + const promise = new Promise((resolve, reject) => { + reader.addEventListener("load", evt => resolve(evt.target.result)); + reader.addEventListener("error", evt => reject(evt.target.error)); + }); + reader.readAsText(this.blob, "utf-8"); + return promise; + } } get url() { @@ -86,6 +124,14 @@ export class BufferHandle { return this._url; } + get size() { + return this.blob.size; + } + + get mimeType() { + return this.blob.type; + } + dispose() { if (this._url) { URL.revokeObjectURL(this._url); diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index 77874017..8396176c 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -153,8 +153,9 @@ class DeriveCrypto { } class AESCrypto { - constructor(subtleCrypto) { + constructor(subtleCrypto, crypto) { this._subtleCrypto = subtleCrypto; + this._crypto = crypto; } /** * [decrypt description] @@ -228,6 +229,27 @@ class AESCrypto { throw new Error(`Could not decrypt with AES-CTR: ${err.message}`); } } + + /** + * Generate a CTR key + * @param {String} format "raw" or "jwk" + * @param {Number} length 128 or 256 + * @return {Promise} an object for jwk, or a BufferSource for raw + */ + async generateKey(format, length = 256) { + const cryptoKey = await subtleCryptoResult(this._subtleCrypto.generateKey( + {"name": "AES-CTR", length}, true, ["encrypt", "decrypt"])); + return subtleCryptoResult(this._subtleCrypto.exportKey("jwk", cryptoKey)); + } + + async generateIV() { + const randomBytes = this._crypto.getRandomValues(new Uint8Array(8)); + const ivArray = new Uint8Array(16); + for (let i = 0; i < randomBytes.length; i += 1) { + ivArray[i] = randomBytes[i]; + } + return ivArray; + } } @@ -291,9 +313,9 @@ export class Crypto { // not exactly guaranteeing AES-CTR support // but in practice IE11 doesn't have this if (!subtleCrypto.deriveBits && cryptoExtras?.aesjs) { - this.aes = new AESLegacyCrypto(cryptoExtras.aesjs); + this.aes = new AESLegacyCrypto(cryptoExtras.aesjs, crypto); } else { - this.aes = new AESCrypto(subtleCrypto); + this.aes = new AESCrypto(subtleCrypto, crypto); } this.hmac = new HMACCrypto(subtleCrypto); this.derive = new DeriveCrypto(subtleCrypto, this, cryptoExtras);