diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index cf46899a..57eb75f0 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -95,6 +95,9 @@ export class TilesCollection extends BaseObservableList { onUnsubscribeLast() { this._entrySubscription = this._entrySubscription(); + for(let i = 0; i < this._tiles.length; i+= 1) { + this._tiles[i].dispose(); + } this._tiles = null; } diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index e72d28e4..670ec774 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -20,20 +20,47 @@ const MAX_HEIGHT = 300; const MAX_WIDTH = 400; export class ImageTile extends MessageTile { + + constructor(options) { + super(options); + this._decryptedUrl = null; + this.load(); + } + + async load() { + const thumbnailFile = this._getContent().file; + if (thumbnailFile) { + const buffer = await this._mediaRepository.downloadEncryptedFile(thumbnailFile); + // TODO: fix XSS bug here by not checking mimetype + const blob = new Blob([buffer], {type: thumbnailFile.mimetype}); + if (this.isDisposed) { + return; + } + this._decryptedUrl = URL.createObjectURL(blob); + this.emitChange("thumbnailUrl"); + } + } + get thumbnailUrl() { + if (this._decryptedUrl) { + return this._decryptedUrl; + } const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); } - return null; + return ""; } get url() { + if (this._decryptedUrl) { + return this._decryptedUrl; + } const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { return this._mediaRepository.mxcUrl(mxcUrl); } - return null; + return ""; } _scaleFactor() { @@ -62,4 +89,12 @@ export class ImageTile extends MessageTile { get shape() { return "image"; } + + dispose() { + if (this._decryptedUrl) { + URL.revokeObjectURL(this._decryptedUrl); + this._decryptedUrl = null; + } + super.dispose(); + } } diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index d59d6c49..fdcd0a96 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -168,6 +168,11 @@ export class SessionContainer { } this._requestScheduler = new RequestScheduler({hsApi, clock: this._clock}); this._requestScheduler.start(); + const mediaRepository = new MediaRepository({ + homeServer: sessionInfo.homeServer, + cryptoDriver: this._cryptoDriver, + request: this._request, + }); this._session = new Session({ storage: this._storage, sessionInfo: filteredSessionInfo, @@ -176,7 +181,7 @@ export class SessionContainer { clock: this._clock, olmWorker, cryptoDriver: this._cryptoDriver, - mediaRepository: new MediaRepository(sessionInfo.homeServer) + mediaRepository }); await this._session.load(); if (isNewLogin) { diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 3c5eda8a..354423b8 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -75,7 +75,8 @@ export class HomeServerApi { method, headers, body: bodyString, - timeout: options?.timeout + timeout: options?.timeout, + format: "json" }); const wrapper = new RequestWrapper(method, url, requestResult); diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index 63fde496..1bba287d 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -15,17 +15,20 @@ limitations under the License. */ import {encodeQueryParams} from "./common.js"; +import {decryptAttachment} from "../e2ee/attachment.js"; export class MediaRepository { - constructor(homeserver) { - this._homeserver = homeserver; + constructor({homeServer, cryptoDriver, request}) { + this._homeServer = homeServer; + this._cryptoDriver = cryptoDriver; + this._request = request; } mxcUrlThumbnail(url, width, height, method) { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + const httpUrl = `${this._homeServer}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; return httpUrl + "?" + encodeQueryParams({width, height, method}); } return null; @@ -35,7 +38,7 @@ export class MediaRepository { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + return `${this._homeServer}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; } else { return null; } @@ -49,4 +52,11 @@ export class MediaRepository { return null; } } + + async downloadEncryptedFile(fileEntry) { + const url = this.mxcUrl(fileEntry.url); + const {body: encryptedBuffer} = await this._request(url, {format: "buffer"}).response(); + const decryptedBuffer = await decryptAttachment(this._cryptoDriver, encryptedBuffer, fileEntry); + return decryptedBuffer; + } } diff --git a/src/matrix/net/request/fetch.js b/src/matrix/net/request/fetch.js index eaa891ed..fa7f8727 100644 --- a/src/matrix/net/request/fetch.js +++ b/src/matrix/net/request/fetch.js @@ -51,8 +51,9 @@ class RequestResult { } export function createFetchRequest(createTimeout) { - return function fetchRequest(url, options) { + return function fetchRequest(url, {method, headers, body, timeout, format}) { const controller = typeof AbortController === "function" ? new AbortController() : null; + let options = {method, body}; if (controller) { options = Object.assign(options, { signal: controller.signal @@ -76,18 +77,22 @@ export function createFetchRequest(createTimeout) { // cache: "no-store", cache: "default", }); - if (options.headers) { - const headers = new Headers(); - for(const [name, value] of options.headers.entries()) { - headers.append(name, value); + if (headers) { + const fetchHeaders = new Headers(); + for(const [name, value] of headers.entries()) { + fetchHeaders.append(name, value); } - options.headers = headers; + options.headers = fetchHeaders; } const promise = fetch(url, options).then(async response => { const {status} = response; let body; try { - body = await response.json(); + if (format === "json") { + body = await response.json(); + } else if (format === "buffer") { + body = await response.arrayBuffer(); + } } catch (err) { // some error pages return html instead of json, ignore error if (!(err.name === "SyntaxError" && status >= 400)) { @@ -105,14 +110,14 @@ export function createFetchRequest(createTimeout) { // // One could check navigator.onLine to rule out the first // but the 2 latter ones are indistinguishable from javascript. - throw new ConnectionError(`${options.method} ${url}: ${err.message}`); + throw new ConnectionError(`${method} ${url}: ${err.message}`); } throw err; }); const result = new RequestResult(promise, controller); - if (options.timeout) { - result.promise = abortOnTimeout(createTimeout, options.timeout, result, result.promise); + if (timeout) { + result.promise = abortOnTimeout(createTimeout, timeout, result, result.promise); } return result; diff --git a/src/matrix/ssss/SecretStorage.js b/src/matrix/ssss/SecretStorage.js index a94c2e19..fdea7187 100644 --- a/src/matrix/ssss/SecretStorage.js +++ b/src/matrix/ssss/SecretStorage.js @@ -64,8 +64,11 @@ export class SecretStorage { throw new Error("Bad MAC"); } - const plaintextBytes = await this._cryptoDriver.aes.decrypt( - aesKey, base64.decode(encryptedData.iv), ciphertextBytes); + const plaintextBytes = await this._cryptoDriver.aes.decryptCTR({ + key: aesKey, + iv: base64.decode(encryptedData.iv), + data: ciphertextBytes + }); return textDecoder.decode(plaintextBytes); } diff --git a/src/ui/web/dom/CryptoDriver.js b/src/ui/web/dom/CryptoDriver.js index 66bb35c0..5d392c03 100644 --- a/src/ui/web/dom/CryptoDriver.js +++ b/src/ui/web/dom/CryptoDriver.js @@ -158,22 +158,26 @@ class CryptoAESDriver { } /** * [decrypt description] - * @param {BufferSource} key [description] + * @param {string} keyFormat "raw" or "jwk" + * @param {BufferSource | Object} key [description] * @param {BufferSource} iv [description] - * @param {BufferSource} ciphertext [description] + * @param {BufferSource} data [description] + * @param {Number} counterLength the size of the counter, in bits * @return {BufferSource} [description] */ - async decrypt(key, iv, ciphertext) { + async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) { const opts = { name: "AES-CTR", counter: iv, - length: 64, + length: counterLength, }; let aesKey; try { + const selectedKey = key || jwkKey; + const format = jwkKey ? "jwk" : "raw"; aesKey = await subtleCryptoResult(this._subtleCrypto.importKey( - 'raw', - key, + format, + selectedKey, opts, false, ['decrypt'], @@ -186,7 +190,7 @@ class CryptoAESDriver { // see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams opts, aesKey, - ciphertext, + data, ), "decrypt"); return new Uint8Array(plaintext); } catch (err) { @@ -205,12 +209,24 @@ class CryptoLegacyAESDriver { * @param {BufferSource} key [description] * @param {BufferSource} iv [description] * @param {BufferSource} ciphertext [description] + * @param {Number} counterLength the size of the counter, in bits * @return {BufferSource} [description] */ - async decrypt(key, iv, ciphertext) { + async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) { + // TODO: support counterLength and jwkKey const aesjs = this._aesjs; + // This won't work as aesjs throws with iv.length !== 16 + // const nonceLength = 8; + // const expectedIVLength = (counterLength / 8) + nonceLength; + // if (iv.length < expectedIVLength) { + // const newIV = new Uint8Array(expectedIVLength); + // for(let i = 0; i < iv.length; ++i) { + // newIV[i] = iv[i]; + // } + // iv = newIV; + // } var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv))); - return aesCtr.decrypt(new Uint8Array(ciphertext)); + return aesCtr.decrypt(new Uint8Array(data)); } } diff --git a/src/ui/web/session/room/timeline/ImageView.js b/src/ui/web/session/room/timeline/ImageView.js index 69360b75..0cc918f7 100644 --- a/src/ui/web/session/room/timeline/ImageView.js +++ b/src/ui/web/session/room/timeline/ImageView.js @@ -23,14 +23,14 @@ export class ImageView extends TemplateView { const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; const image = t.img({ className: "picture", - src: vm.thumbnailUrl, + src: vm => vm.thumbnailUrl, width: vm.thumbnailWidth, height: vm.thumbnailHeight, loading: "lazy", alt: vm.label, }); const linkContainer = t.a({ - href: vm.url, + href: vm => vm.url, target: "_blank", style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` }, image);