From a37d8c0223a96d52cc381149e0c80d25163ce58e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 19:48:36 +0100 Subject: [PATCH 01/22] implement AES encryption --- src/platform/web/dom/Crypto.js | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index f80c1a40..77874017 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -197,6 +197,37 @@ class AESCrypto { throw new Error(`Could not decrypt with AES-CTR: ${err.message}`); } } + + async encryptCTR({key, iv, data}) { + const opts = { + name: "AES-CTR", + counter: iv, + length: 64, + }; + let aesKey; + try { + aesKey = await subtleCryptoResult(this._subtleCrypto.importKey( + "raw", + key, + opts, + false, + ['encrypt'], + ), "importKey"); + } catch (err) { + throw new Error(`Could not import key for AES-CTR decryption: ${err.message}`); + } + try { + const ciphertext = await subtleCryptoResult(this._subtleCrypto.encrypt( + // see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams + opts, + aesKey, + data, + ), "encrypt"); + return new Uint8Array(ciphertext); + } catch (err) { + throw new Error(`Could not decrypt with AES-CTR: ${err.message}`); + } + } } @@ -237,6 +268,12 @@ class AESLegacyCrypto { var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv))); return aesCtr.decrypt(new Uint8Array(data)); } + + async encryptCTR({key, iv, data}) { + const aesjs = this._aesjs; + var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv))); + return aesCtr.encrypt(new Uint8Array(data)); + } } function hashName(name) { From 2cfffa015d08ea78da979c1a0a2342e89e14c8e6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 10 Nov 2020 22:36:26 +0100 Subject: [PATCH 02/22] 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); From c278b0f4a30b5ee7958c9e99e5e5c1516ca8a4ba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:44:44 +0100 Subject: [PATCH 03/22] rename blob.blob to blob.nativeBlob also to make the name a bit more unique where we want to do duck typing --- src/platform/web/Platform.js | 4 ++-- src/platform/web/dom/BlobHandle.js | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 66373844..6c28e95a 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -137,9 +137,9 @@ export class Platform { saveFileAs(blobHandle, filename) { if (navigator.msSaveBlob) { - navigator.msSaveBlob(blobHandle.blob, filename); + navigator.msSaveBlob(blobHandle.nativeBlob, filename); } else { - downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle.blob, filename); + downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle.nativeBlob, filename); } } diff --git a/src/platform/web/dom/BlobHandle.js b/src/platform/web/dom/BlobHandle.js index 2da3a776..00098de1 100644 --- a/src/platform/web/dom/BlobHandle.js +++ b/src/platform/web/dom/BlobHandle.js @@ -71,7 +71,7 @@ const ALLOWED_BLOB_MIMETYPES = { export class BlobHandle { constructor(blob, buffer = null) { - this.blob = blob; + this._blob = blob; this._buffer = buffer; this._url = null; } @@ -89,6 +89,10 @@ export class BlobHandle { return new BlobHandle(file); } + get nativeBlob() { + return this._blob; + } + async readAsBuffer() { if (this._buffer) { return this._buffer; @@ -98,7 +102,7 @@ export class BlobHandle { reader.addEventListener("load", evt => resolve(evt.target.result)); reader.addEventListener("error", evt => reject(evt.target.error)); }); - reader.readAsArrayBuffer(this.blob); + reader.readAsArrayBuffer(this._blob); return promise; } } @@ -112,24 +116,24 @@ export class BlobHandle { reader.addEventListener("load", evt => resolve(evt.target.result)); reader.addEventListener("error", evt => reject(evt.target.error)); }); - reader.readAsText(this.blob, "utf-8"); + reader.readAsText(this._blob, "utf-8"); return promise; } } get url() { if (!this._url) { - this._url = URL.createObjectURL(this.blob); + this._url = URL.createObjectURL(this._blob); } return this._url; } get size() { - return this.blob.size; + return this._blob.size; } get mimeType() { - return this.blob.type; + return this._blob.type; } dispose() { From 9f2c7c1e32d8fe6d2af7466654c98c4ead98585d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:45:23 +0100 Subject: [PATCH 04/22] support uploading blobs in hs api --- src/matrix/net/HomeServerApi.js | 32 ++++++++++++++++++++++----- src/platform/web/dom/request/fetch.js | 4 ++++ src/platform/web/dom/request/xhr.js | 4 ++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index e6c97433..81da33d1 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -55,6 +55,26 @@ class RequestWrapper { } } +function encodeBody(body) { + if (body.nativeBlob && body.mimeType) { + const blob = body; + return { + mimeType: blob.mimeType, + body: blob, // will be unwrapped in request fn + length: blob.size + }; + } else if (typeof body === "object") { + const json = JSON.stringify(body); + return { + mimeType: "application/json", + body: json, + length: body.length + }; + } else { + throw new Error("Unknown body type: " + body); + } +} + export class HomeServerApi { constructor({homeServer, accessToken, request, createTimeout, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? @@ -73,22 +93,24 @@ export class HomeServerApi { _baseRequest(method, url, queryParams, body, options, accessToken) { const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; - let bodyString; + let encodedBody; const headers = new Map(); if (accessToken) { headers.set("Authorization", `Bearer ${accessToken}`); } headers.set("Accept", "application/json"); if (body) { - headers.set("Content-Type", "application/json"); - bodyString = JSON.stringify(body); + const encoded = encodeBody(body); + headers.set("Content-Type", encoded.mimeType); + headers.set("Content-Length", encoded.length); + encodedBody = encoded.body; } const requestResult = this._requestFn(url, { method, headers, - body: bodyString, + body: encodedBody, timeout: options?.timeout, - format: "json" + format: "json" // response format }); const wrapper = new RequestWrapper(method, url, requestResult); diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 13e2b32f..96c9ff9c 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -53,6 +53,10 @@ class RequestResult { export function createFetchRequest(createTimeout) { return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) { const controller = typeof AbortController === "function" ? new AbortController() : null; + // if a BlobHandle, take native blob + if (body?.nativeBlob) { + body = body.nativeBlob; + } let options = {method, body}; if (controller) { options = Object.assign(options, { diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index 9574c8cf..5ca2d460 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -52,6 +52,10 @@ function send(url, {method, headers, timeout, body, format}) { xhr.timeout = timeout; } + // if a BlobHandle, take native blob + if (body?.nativeBlob) { + body = body.nativeBlob; + } xhr.send(body || null); return xhr; From 2890a6aff7ad2ec8cf2352011e7019e5c539e509 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:45:44 +0100 Subject: [PATCH 05/22] implement attachment uploading in hs api rather than media repo as we need the access token --- src/matrix/net/HomeServerApi.js | 4 ++++ src/matrix/net/MediaRepository.js | 7 ------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 81da33d1..1f2acab4 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -220,6 +220,10 @@ export class HomeServerApi { roomKeyForRoomAndSession(version, roomId, sessionId, options = null) { return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options); } + + uploadAttachment(blob, filename, options = null) { + return this._post("/upload", {filename}, blob, options); + } } export function tests() { diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index 8ab37a87..2e7ec438 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -72,11 +72,4 @@ export class MediaRepository { return this.downloadPlaintextFile(content.url, content.info?.mimetype, cache); } } - - 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; - } } From 32b7b1f06473aae318005da50e0e33b4d8aa9375 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:46:20 +0100 Subject: [PATCH 06/22] draft of AttachmentUpload class --- src/matrix/room/AttachmentUpload.js | 94 +++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/matrix/room/AttachmentUpload.js diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js new file mode 100644 index 00000000..96ce9b02 --- /dev/null +++ b/src/matrix/room/AttachmentUpload.js @@ -0,0 +1,94 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {encryptAttachment} from "../e2ee/attachment.js"; + +export class AttachmentUpload { + constructor({filename, blob, hsApi, platform, isEncrypted}) { + this._filename = filename; + this._unencryptedBlob = blob; + this._isEncrypted = isEncrypted; + this._platform = platform; + this._hsApi = hsApi; + this._mxcUrl = null; + this._transferredBlob = null; + this._encryptionInfo = null; + this._uploadPromise = null; + this._uploadRequest = null; + this._aborted = false; + } + + upload() { + if (!this._uploadPromise) { + this._uploadPromise = this._upload(); + } + return this._uploadPromise; + } + + async _upload() { + let transferredBlob = this._unencryptedBlob; + if (this.isEncrypted) { + const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); + transferredBlob = blob; + this._encryptionInfo = info; + } + if (this._aborted) { + return; + } + this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); + const {content_uri} = await this._uploadRequest.response(); + this._mxcUrl = content_uri; + this._transferredBlob = transferredBlob; + } + + /** @public */ + abort() { + this._aborted = true; + this._uploadRequest?.abort(); + } + + /** @public */ + get localPreview() { + return this._unencryptedBlob; + } + + /** @package */ + uploaded() { + if (!this._uploadPromise) { + throw new Error("upload has not started yet"); + } + return this._uploadPromise; + } + + /** @package */ + applyToContent(content) { + if (!this._mxcUrl) { + throw new Error("upload has not finished"); + } + content.info = { + size: this._transferredBlob.size, + mimetype: this._unencryptedBlob.mimeType, + }; + if (this._isEncrypted) { + content.file = Object.assign(this._encryptionInfo, { + mimetype: this._unencryptedBlob.mimeType, + url: this._mxcUrl + }); + } else { + content.url = this._mxcUrl; + } + } +} From c289bcd0978d1c577fa8ddc801eee7c95585f095 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:46:37 +0100 Subject: [PATCH 07/22] return blob from encryptAttachment --- src/matrix/e2ee/attachment.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index cea91cfa..02a051ed 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -57,13 +57,15 @@ export async function decryptAttachment(crypto, ciphertextBuffer, info) { return decryptedBuffer; } -export async function encryptAttachment(crypto, data) { +export async function encryptAttachment(platform, blob) { + const {crypto} = platform; const iv = await crypto.aes.generateIV(); const key = await crypto.aes.generateKey("jwk", 256); - const ciphertext = await crypto.aes.encryptCTR({key, iv, data}); + const buffer = await blob.readAsBuffer(); + const ciphertext = await crypto.aes.encryptCTR({key, iv, data: buffer}); const digest = await crypto.digest("SHA-256", ciphertext); return { - data: ciphertext, + blob: platform.createBlob(ciphertext, blob.mimeType), info: { v: "v2", key, From 0c70a67ebb4702484007a5db0f00d858e9a975b2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:47:19 +0100 Subject: [PATCH 08/22] pass platform to room as we'll need it to access crypto and creating blobs --- src/matrix/Session.js | 2 +- src/matrix/room/Room.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 15487ec5..ace33fa8 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -362,7 +362,7 @@ export class Session { pendingEvents, user: this._user, createRoomEncryption: this._createRoomEncryption, - clock: this._platform.clock + platform: this._platform }); this._rooms.add(roomId, room); return room; diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8427239c..65e3c9b4 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -34,7 +34,7 @@ import {DecryptionSource} from "../e2ee/common.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends EventEmitter { - constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) { + constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, platform}) { super(); this._roomId = roomId; this._storage = storage; @@ -52,7 +52,7 @@ export class Room extends EventEmitter { this._createRoomEncryption = createRoomEncryption; this._roomEncryption = null; this._getSyncToken = getSyncToken; - this._clock = clock; + this._platform = platform; this._observedEvents = null; } @@ -569,7 +569,7 @@ export class Room extends EventEmitter { } }, user: this._user, - clock: this._clock + clock: this._platform.clock }); if (this._roomEncryption) { this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); From 7088b2cdc87ecbc3c8fbc0e69d75145239d5e317 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 10:47:55 +0100 Subject: [PATCH 09/22] support attachment upload and sending from room api --- src/domain/session/room/RoomViewModel.js | 29 ++++++------------------ src/matrix/room/Room.js | 14 ++++++++++-- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 3b447c60..bad1ecbd 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -165,33 +165,18 @@ export class RoomViewModel extends ViewModel { } 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 - }); + let file; + try { + file = this.platform.openFile(); + } catch (err) { + return; } - const mxcUrl = await this._room.mediaRepository.upload(blob, file.name); + const attachment = this._room.uploadAttachment(file.name, file.blob); 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); + await this._room.sendEvent("m.room.message", content, attachment); } get composerViewModel() { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 65e3c9b4..109c096e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -30,7 +30,9 @@ import {EventEntry} from "./timeline/entries/EventEntry.js"; import {EventKey} from "./timeline/EventKey.js"; import {Direction} from "./timeline/Direction.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; +import {AttachmentUpload} from "./AttachmentUpload.js"; import {DecryptionSource} from "../e2ee/common.js"; + const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends EventEmitter { @@ -350,10 +352,11 @@ export class Room extends EventEmitter { } /** @public */ - sendEvent(eventType, content) { - return this._sendQueue.enqueueEvent(eventType, content); + sendEvent(eventType, content, attachment) { + return this._sendQueue.enqueueEvent(eventType, content, attachment); } + /** @public */ async ensureMessageKeyIsShared() { return this._roomEncryption?.ensureMessageKeyIsShared(this._hsApi); } @@ -630,6 +633,13 @@ export class Room extends EventEmitter { } } + uploadAttachment(blob, filename) { + const attachment = new AttachmentUpload({blob, filename, + hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted}); + attachment.upload(); + return attachment; + } + dispose() { this._roomEncryption?.dispose(); this._timeline?.dispose(); From 65d02072c8c4f4feb9c9393e06130b6b23f06d2b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:47:05 +0100 Subject: [PATCH 10/22] hook up UI --- src/domain/session/room/RoomViewModel.js | 4 ++++ src/platform/web/ui/session/room/MessageComposer.js | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index bad1ecbd..6d3fa167 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -204,6 +204,10 @@ class ComposerViewModel extends ViewModel { return success; } + sendAttachment() { + this._roomVM._sendFile(); + } + get canSend() { return !this._isEmpty; } diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 20d1b639..0be8de19 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -30,6 +30,11 @@ export class MessageComposer extends TemplateView { }); return t.div({className: "MessageComposer"}, [ this._input, + t.button({ + className: "attach", + title: vm.i18n`Attach`, + onClick: () => vm.sendAttachment(), + }, vm.i18n`Attach`), t.button({ className: "send", title: vm.i18n`Send`, From e2dd9b9f77e91d65750cd38357dfa2e611ce6d05 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:47:13 +0100 Subject: [PATCH 11/22] fix typos/refactor errors --- src/domain/session/room/RoomViewModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 6d3fa167..c13dd1a3 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -167,11 +167,11 @@ export class RoomViewModel extends ViewModel { async _sendFile() { let file; try { - file = this.platform.openFile(); + file = await this.platform.openFile(); } catch (err) { return; } - const attachment = this._room.uploadAttachment(file.name, file.blob); + const attachment = this._room.uploadAttachment(file.blob, file.name); const content = { body: file.name, msgtype: "m.file", From e9324ad67882addccf3516139ca6bf7837e548a3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:47:39 +0100 Subject: [PATCH 12/22] support jwk keys to encrypt --- src/matrix/e2ee/attachment.js | 2 +- src/platform/web/dom/Crypto.js | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index 02a051ed..070e7c53 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -62,7 +62,7 @@ export async function encryptAttachment(platform, blob) { const iv = await crypto.aes.generateIV(); const key = await crypto.aes.generateKey("jwk", 256); const buffer = await blob.readAsBuffer(); - const ciphertext = await crypto.aes.encryptCTR({key, iv, data: buffer}); + const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer}); const digest = await crypto.digest("SHA-256", ciphertext); return { blob: platform.createBlob(ciphertext, blob.mimeType), diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index 8396176c..be3e4343 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -199,17 +199,19 @@ class AESCrypto { } } - async encryptCTR({key, iv, data}) { + async encryptCTR({key, jwkKey, iv, data}) { const opts = { name: "AES-CTR", counter: iv, length: 64, }; let aesKey; + const selectedKey = key || jwkKey; + const format = jwkKey ? "jwk" : "raw"; try { aesKey = await subtleCryptoResult(this._subtleCrypto.importKey( - "raw", - key, + format, + selectedKey, opts, false, ['encrypt'], From 366f3c0bbacd3ad94107a7efd863893a85cccfd1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:50:20 +0100 Subject: [PATCH 13/22] iv and digest are sent in unpadded base64 --- src/matrix/e2ee/attachment.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index 070e7c53..105c16c5 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -69,10 +69,20 @@ export async function encryptAttachment(platform, blob) { info: { v: "v2", key, - iv: base64.encode(iv), + iv: encodeUnpaddedBase64(iv), hashes: { - sha256: base64.encode(digest) + sha256: encodeUnpaddedBase64(digest) } } }; } + +function encodeUnpaddedBase64(buffer) { + const str = base64.encode(buffer); + const paddingIdx = str.indexOf("="); + if (paddingIdx !== -1) { + return str.substr(0, paddingIdx); + } else { + return str; + } +} From 91f15074bec7dd83cdea22e1aff71f7ab9e75613 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:50:40 +0100 Subject: [PATCH 14/22] /upload is on /media/r0, not /client/r0 --- src/matrix/net/HomeServerApi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 1f2acab4..bdf35363 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -222,7 +222,7 @@ export class HomeServerApi { } uploadAttachment(blob, filename, options = null) { - return this._post("/upload", {filename}, blob, options); + return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options); } } From 48cd4ac95d3757e0cd5348331ed9af3b0079a98b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:51:11 +0100 Subject: [PATCH 15/22] track upload error --- src/matrix/room/AttachmentUpload.js | 38 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js index 96ce9b02..98d81894 100644 --- a/src/matrix/room/AttachmentUpload.js +++ b/src/matrix/room/AttachmentUpload.js @@ -29,6 +29,7 @@ export class AttachmentUpload { this._uploadPromise = null; this._uploadRequest = null; this._aborted = false; + this._error = null; } upload() { @@ -39,19 +40,28 @@ export class AttachmentUpload { } async _upload() { - let transferredBlob = this._unencryptedBlob; - if (this.isEncrypted) { - const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); - transferredBlob = blob; - this._encryptionInfo = info; + try { + let transferredBlob = this._unencryptedBlob; + if (this._isEncrypted) { + const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); + transferredBlob = blob; + this._encryptionInfo = info; + } + if (this._aborted) { + return; + } + this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); + const {content_uri} = await this._uploadRequest.response(); + this._mxcUrl = content_uri; + this._transferredBlob = transferredBlob; + } catch (err) { + this._error = err; + throw err; } - if (this._aborted) { - return; - } - this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); - const {content_uri} = await this._uploadRequest.response(); - this._mxcUrl = content_uri; - this._transferredBlob = transferredBlob; + } + + get isUploaded() { + return !!this._transferredBlob; } /** @public */ @@ -65,6 +75,10 @@ export class AttachmentUpload { return this._unencryptedBlob; } + get error() { + return this._error; + } + /** @package */ uploaded() { if (!this._uploadPromise) { From af4f3f902f2d2fab8d684478435784f251304ad0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:51:39 +0100 Subject: [PATCH 16/22] wait for attachment to be uploaded in send queue also expose attachment from PendingEvent(Entry) --- src/matrix/room/sending/PendingEvent.js | 3 ++- src/matrix/room/sending/SendQueue.js | 13 +++++++++---- .../room/timeline/entries/PendingEventEntry.js | 4 ++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index fb2d1a47..030e57b6 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,8 +15,9 @@ limitations under the License. */ export class PendingEvent { - constructor(data) { + constructor(data, attachment) { this._data = data; + this.attachment = attachment; } get roomId() { return this._data.roomId; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index eba5fcf3..8d367cb2 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -51,6 +51,11 @@ export class SendQueue { this._amountSent += 1; continue; } + if (pendingEvent.attachment) { + const {attachment} = pendingEvent; + await attachment.uploaded(); + attachment.applyToContent(pendingEvent.content); + } if (pendingEvent.needsEncryption) { const {type, content} = await this._roomEncryption.encrypt( pendingEvent.eventType, pendingEvent.content, this._hsApi); @@ -116,8 +121,8 @@ export class SendQueue { } } - async enqueueEvent(eventType, content) { - const pendingEvent = await this._createAndStoreEvent(eventType, content); + async enqueueEvent(eventType, content, attachment) { + const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment); this._pendingEvents.set(pendingEvent); console.log("added to _pendingEvents set", this._pendingEvents.length); if (!this._isSending && !this._offline) { @@ -150,7 +155,7 @@ export class SendQueue { await txn.complete(); } - async _createAndStoreEvent(eventType, content) { + async _createAndStoreEvent(eventType, content, attachment) { console.log("_createAndStoreEvent"); const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); let pendingEvent; @@ -167,7 +172,7 @@ export class SendQueue { content, txnId: makeTxnId(), needsEncryption: !!this._roomEncryption - }); + }, attachment); console.log("_createAndStoreEvent: adding to pendingEventsStore"); pendingEventsStore.add(pendingEvent.data); } catch (err) { diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index f9376eab..9f91c80f 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -64,6 +64,10 @@ export class PendingEventEntry extends BaseEntry { return this._pendingEvent.txnId; } + get attachment() { + return this._pendingEvent.attachment; + } + notifyUpdate() { } From fd9eccec4d0b173c644d767d6a1ff6c02b029826 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 11:52:06 +0100 Subject: [PATCH 17/22] support local echo in FileTile while uploading --- .../session/room/timeline/tiles/FileTile.js | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index 1900c20a..2c293c6c 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -23,10 +23,18 @@ export class FileTile extends MessageTile { super(options); this._error = null; this._downloading = false; + if (this._isUploading) { + // should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves + this._entry.attachment.uploaded().then(() => { + if (!this.isDisposed) { + this.emitChange("label"); + } + }); + } } async download() { - if (this._downloading) { + if (this._downloading || this._isUploading) { return; } const content = this._getContent(); @@ -46,14 +54,31 @@ export class FileTile extends MessageTile { this.emitChange("label"); } + get size() { + if (this._isUploading) { + return this._entry.attachment.localPreview.size; + } else { + return this._getContent().info?.size; + } + } + + get _isUploading() { + return this._entry.attachment && !this._entry.attachment.isUploaded; + } + get label() { if (this._error) { return `Could not decrypt file: ${this._error.message}`; } + if (this._entry.attachment?.error) { + return `Failed to upload: ${this._entry.attachment.error.message}`; + } const content = this._getContent(); const filename = content.body; - const size = formatSize(content.info?.size); - if (this._downloading) { + const size = formatSize(this.size); + if (this._isUploading) { + return this.i18n`Uploading ${filename} (${size})…`; + } else if (this._downloading) { return this.i18n`Downloading ${filename} (${size})…`; } else { return this.i18n`Download ${filename} (${size})`; From 8db7499f5a66cf306dc4c5d37c3dc05170f9e98d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 12:44:59 +0100 Subject: [PATCH 18/22] support AES-CTR 256 JWK keys in legacy crypto for IE11 --- src/platform/web/dom/Crypto.js | 101 ++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index be3e4343..35933310 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -241,25 +241,73 @@ class AESCrypto { 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)); + return subtleCryptoResult(this._subtleCrypto.exportKey(format, 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; + return generateIV(this._crypto); } } +function generateIV(crypto) { + const randomBytes = 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; +} + +function jwkKeyToRaw(jwkKey) { + if (jwkKey.alg !== "A256CTR") { + throw new Error(`Unknown algorithm: ${jwkKey.alg}`); + } + if (!jwkKey.key_ops.includes("decrypt")) { + throw new Error(`decrypt missing from key_ops`); + } + if (jwkKey.kty !== "oct") { + throw new Error(`Invalid key type, "oct" expected: ${jwkKey.kty}`); + } + // convert base64-url to normal base64 + const base64UrlKey = jwkKey.k; + const base64Key = base64UrlKey.replace(/-/g, "+").replace(/_/g, "/"); + return base64.decode(base64Key); +} + +function encodeUnpaddedBase64(buffer) { + const str = base64.encode(buffer); + const paddingIdx = str.indexOf("="); + if (paddingIdx !== -1) { + return str.substr(0, paddingIdx); + } else { + return str; + } +} + +function encodeUrlBase64(buffer) { + const unpadded = encodeUnpaddedBase64(buffer); + return unpadded.replace(/\+/g, "-").replace(/\//g, "_"); +} + +function rawKeyToJwk(key) { + return { + "alg": "A256CTR", + "ext": true, + "k": encodeUrlBase64(key), + "key_ops": [ + "encrypt", + "decrypt" + ], + "kty": "oct" + }; +} import base64 from "../../../../lib/base64-arraybuffer/index.js"; class AESLegacyCrypto { - constructor(aesjs) { + constructor(aesjs, crypto) { this._aesjs = aesjs; + this._crypto = crypto; } /** * [decrypt description] @@ -274,30 +322,39 @@ class AESLegacyCrypto { throw new Error(`Unsupported counter length: ${counterLength}`); } if (jwkKey) { - if (jwkKey.alg !== "A256CTR") { - throw new Error(`Unknown algorithm: ${jwkKey.alg}`); - } - if (!jwkKey.key_ops.includes("decrypt")) { - throw new Error(`decrypt missing from key_ops`); - } - if (jwkKey.kty !== "oct") { - throw new Error(`Invalid key type, "oct" expected: ${jwkKey.kty}`); - } - // convert base64-url to normal base64 - const base64UrlKey = jwkKey.k; - const base64Key = base64UrlKey.replace(/-/g, "+").replace(/_/g, "/"); - key = base64.decode(base64Key); + key = jwkKeyToRaw(jwkKey); } const aesjs = this._aesjs; var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv))); return aesCtr.decrypt(new Uint8Array(data)); } - async encryptCTR({key, iv, data}) { + async encryptCTR({key, jwkKey, iv, data}) { + if (jwkKey) { + key = jwkKeyToRaw(jwkKey); + } const aesjs = this._aesjs; var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv))); return aesCtr.encrypt(new Uint8Array(data)); } + + /** + * 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) { + let key = crypto.getRandomValues(new Uint8Array(length / 8)); + if (format === "jwk") { + key = rawKeyToJwk(key); + } + return key; + } + + async generateIV() { + return generateIV(this._crypto); + } } function hashName(name) { From 63847028634f65fa55a9d293f84d2a28aa3e3d8e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 12:47:26 +0100 Subject: [PATCH 19/22] fix file selector for IE11 --- src/platform/web/Platform.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 6c28e95a..6a981c64 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -146,6 +146,7 @@ export class Platform { openFile(mimeType = null) { const input = document.createElement("input"); input.setAttribute("type", "file"); + input.className = "hidden"; if (mimeType) { input.setAttribute("accept", mimeType); } @@ -153,6 +154,7 @@ export class Platform { const checkFile = () => { input.removeEventListener("change", checkFile, true); const file = input.files[0]; + this._container.removeChild(input); if (file) { resolve({name: file.name, blob: BlobHandle.fromFile(file)}); } else { @@ -161,6 +163,8 @@ export class Platform { } input.addEventListener("change", checkFile, true); }); + // IE11 needs the input to be attached to the document + this._container.appendChild(input); input.click(); return promise; } From d477be2b41a689f20bb4149dbdba24b8c83da609 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 12:57:43 +0100 Subject: [PATCH 20/22] put paperclip in send file button --- .../web/ui/css/themes/element/icons/paperclip.svg | 3 +++ src/platform/web/ui/css/themes/element/theme.css | 14 ++++++++++++++ .../web/ui/session/room/MessageComposer.js | 6 +++--- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 src/platform/web/ui/css/themes/element/icons/paperclip.svg diff --git a/src/platform/web/ui/css/themes/element/icons/paperclip.svg b/src/platform/web/ui/css/themes/element/icons/paperclip.svg new file mode 100644 index 00000000..81a8bf06 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/paperclip.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index d2b0d229..57bfc03b 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -470,6 +470,20 @@ a { background-position: center; } +.MessageComposer > button.sendFile { + width: 32px; + height: 32px; + display: block; + border: none; + text-indent: 200%; + white-space: nowrap; + overflow: hidden; + background-color: transparent; + background-image: url('icons/paperclip.svg'); + background-repeat: no-repeat; + background-position: center; +} + .MessageComposer > button.send:disabled { background-color: #E3E8F0; } diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index 0be8de19..62b55b15 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -31,10 +31,10 @@ export class MessageComposer extends TemplateView { return t.div({className: "MessageComposer"}, [ this._input, t.button({ - className: "attach", - title: vm.i18n`Attach`, + className: "sendFile", + title: vm.i18n`Send file`, onClick: () => vm.sendAttachment(), - }, vm.i18n`Attach`), + }, vm.i18n`Send file`), t.button({ className: "send", title: vm.i18n`Send`, From 8b16d8bc99cb2795091e8648716a9b5eedf3fa03 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 13:06:03 +0100 Subject: [PATCH 21/22] handle upload failures better --- src/domain/session/room/timeline/tiles/FileTile.js | 2 +- src/matrix/room/sending/SendQueue.js | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index 2c293c6c..eab55136 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -25,7 +25,7 @@ export class FileTile extends MessageTile { this._downloading = false; if (this._isUploading) { // should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves - this._entry.attachment.uploaded().then(() => { + this._entry.attachment.uploaded().finally(() => { if (!this.isDisposed) { this.emitChange("label"); } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 8d367cb2..76ff2c66 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -53,7 +53,13 @@ export class SendQueue { } if (pendingEvent.attachment) { const {attachment} = pendingEvent; - await attachment.uploaded(); + try { + await attachment.uploaded(); + } catch (err) { + console.log("upload failed, skip sending message", pendingEvent); + this._amountSent += 1; + continue; + } attachment.applyToContent(pendingEvent.content); } if (pendingEvent.needsEncryption) { From f23312b0d344738858be4611d963df554d855a75 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 11 Nov 2020 13:17:05 +0100 Subject: [PATCH 22/22] fix decrypt/encrypt wording in error message --- src/platform/web/dom/Crypto.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index 35933310..2362ecda 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -217,7 +217,7 @@ class AESCrypto { ['encrypt'], ), "importKey"); } catch (err) { - throw new Error(`Could not import key for AES-CTR decryption: ${err.message}`); + throw new Error(`Could not import key for AES-CTR encryption: ${err.message}`); } try { const ciphertext = await subtleCryptoResult(this._subtleCrypto.encrypt( @@ -228,7 +228,7 @@ class AESCrypto { ), "encrypt"); return new Uint8Array(ciphertext); } catch (err) { - throw new Error(`Could not decrypt with AES-CTR: ${err.message}`); + throw new Error(`Could not encrypt with AES-CTR: ${err.message}`); } }