Merge pull request #175 from vector-im/bwindels/decrypt-images

Decrypt images
This commit is contained in:
Bruno Windels 2020-10-29 09:42:39 +00:00 committed by GitHub
commit 6d13709fcc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 372 additions and 65 deletions

View file

@ -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;
}
@ -147,7 +150,8 @@ export class TilesCollection extends BaseObservableList {
if (action.shouldReplace) {
const newTile = this._tileCreator(entry);
if (newTile) {
this._replaceTile(tileIdx, tile, newTile);
this._replaceTile(tileIdx, tile, newTile, action.updateParams);
newTile.setUpdateEmit(this._emitSpontanousUpdate);
} else {
this._removeTile(tileIdx, tile);
}
@ -172,7 +176,7 @@ export class TilesCollection extends BaseObservableList {
// merge with neighbours? ... hard to imagine use case for this ...
}
_replaceTile(tileIdx, existingTile, newTile) {
_replaceTile(tileIdx, existingTile, newTile, updateParams) {
existingTile.dispose();
const prevTile = this._getTileAtIdx(tileIdx - 1);
const nextTile = this._getTileAtIdx(tileIdx + 1);
@ -181,7 +185,7 @@ export class TilesCollection extends BaseObservableList {
newTile.updatePreviousSibling(prevTile);
newTile.updateNextSibling(nextTile);
nextTile?.updatePreviousSibling(newTile);
this.emitUpdate(tileIdx, newTile, null);
this.emitUpdate(tileIdx, newTile, updateParams);
}
_removeTile(tileIdx, tile) {

View file

@ -50,7 +50,7 @@ export class UpdateAction {
return new UpdateAction(false, false, false, null);
}
static Replace() {
return new UpdateAction(false, false, true, null);
static Replace(params) {
return new UpdateAction(false, false, true, params);
}
}

View file

@ -22,7 +22,8 @@ export class EncryptedEventTile extends MessageTile {
const parentResult = super.updateEntry(entry, params);
// event got decrypted, recreate the tile and replace this one with it
if (entry.eventType !== "m.room.encrypted") {
return UpdateAction.Replace();
// the "shape" parameter trigger tile recreation in TimelineList
return UpdateAction.Replace("shape");
} else {
return parentResult;
}
@ -38,7 +39,7 @@ export class EncryptedEventTile extends MessageTile {
if (code === "MEGOLM_NO_SESSION") {
return this.i18n`The sender hasn't sent us the key for this message yet.`;
} else {
return decryptionError?.message || this.i18n`"Could not decrypt message because of unknown reason."`;
return decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
}
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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.
@ -20,20 +21,60 @@ const MAX_HEIGHT = 300;
const MAX_WIDTH = 400;
export class ImageTile extends MessageTile {
constructor(options) {
super(options);
this._decryptedThumbail = null;
this._decryptedImage = null;
this._error = null;
this.load();
}
async _loadEncryptedFile(file) {
const buffer = await this._mediaRepository.downloadEncryptedFile(file);
if (this.isDisposed) {
return;
}
return this.track(this.platform.createBufferURL(buffer, file.mimetype));
}
async load() {
try {
const thumbnailFile = this._getContent().info?.thumbnail_file;
const file = this._getContent().file;
if (thumbnailFile) {
this._decryptedThumbail = await this._loadEncryptedFile(thumbnailFile);
this.emitChange("thumbnailUrl");
} else if (file) {
this._decryptedImage = await this._loadEncryptedFile(file);
this.emitChange("thumbnailUrl");
}
} catch (err) {
this._error = err;
this.emitChange("error");
}
}
get thumbnailUrl() {
if (this._decryptedThumbail) {
return this._decryptedThumbail.url;
} else if (this._decryptedImage) {
return this._decryptedImage.url;
}
const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") {
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
}
return null;
return "";
}
get url() {
const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") {
return this._mediaRepository.mxcUrl(mxcUrl);
async loadImageUrl() {
if (!this._decryptedImage) {
const file = this._getContent().file;
if (file) {
this._decryptedImage = await this._loadEncryptedFile(file);
}
}
return null;
return this._decryptedImage?.url || "";
}
_scaleFactor() {
@ -59,6 +100,13 @@ export class ImageTile extends MessageTile {
return this._getContent().body;
}
get error() {
if (this._error) {
return `Could not decrypt image: ${this._error.message}`;
}
return null;
}
get shape() {
return "image";
}

View file

@ -18,9 +18,9 @@ import {UpdateAction} from "../UpdateAction.js";
import {ViewModel} from "../../../../ViewModel.js";
export class SimpleTile extends ViewModel {
constructor({entry}) {
super();
this._entry = entry;
constructor(options) {
super(options);
this._entry = options.entry;
}
// view model props for all subclasses
// hmmm, could also do instanceof ... ?

View file

@ -165,14 +165,19 @@ export class SessionContainer {
}
this._requestScheduler = new RequestScheduler({hsApi, clock});
this._requestScheduler.start();
const mediaRepository = new MediaRepository({
homeServer: sessionInfo.homeServer,
crypto: this._platform.crypto,
request: this._platform.request,
});
this._session = new Session({
storage: this._storage,
sessionInfo: filteredSessionInfo,
hsApi: this._requestScheduler.hsApi,
olm,
olmWorker,
mediaRepository,
platform: this._platform,
mediaRepository: new MediaRepository(sessionInfo.homeServer)
});
await this._session.load();
if (isNewLogin) {

View file

@ -0,0 +1,58 @@
/*
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 base64 from "../../../lib/base64-arraybuffer/index.js";
/**
* Decrypt an attachment.
* @param {ArrayBuffer} ciphertextBuffer The encrypted attachment data buffer.
* @param {Object} info The information needed to decrypt the attachment.
* @param {Object} info.key AES-CTR JWK key object.
* @param {string} info.iv Base64 encoded 16 byte AES-CTR IV.
* @param {string} info.hashes.sha256 Base64 encoded SHA-256 hash of the ciphertext.
* @return {Promise} A promise that resolves with an ArrayBuffer when the attachment is decrypted.
*/
export async function decryptAttachment(crypto, ciphertextBuffer, info) {
if (info === undefined || info.key === undefined || info.iv === undefined
|| info.hashes === undefined || info.hashes.sha256 === undefined) {
throw new Error("Invalid info. Missing info.key, info.iv or info.hashes.sha256 key");
}
var ivArray = base64.decode(info.iv);
// re-encode to not deal with padded vs unpadded
var expectedSha256base64 = base64.encode(base64.decode(info.hashes.sha256));
// Check the sha256 hash
const digestResult = await crypto.digest("SHA-256", ciphertextBuffer);
if (base64.encode(new Uint8Array(digestResult)) != expectedSha256base64) {
throw new Error("Mismatched SHA-256 digest");
}
var counterLength;
if (info.v == "v1" || info.v == "v2") {
// Version 1 and 2 use a 64 bit counter.
counterLength = 64;
} else {
// Version 0 uses a 128 bit counter.
counterLength = 128;
}
const decryptedBuffer = await crypto.aes.decryptCTR({
jwkKey: info.key,
iv: ivArray,
data: ciphertextBuffer,
counterLength
});
return decryptedBuffer;
}

View file

@ -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);

View file

@ -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, crypto, request}) {
this._homeServer = homeServer;
this._crypto = crypto;
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, {method: "GET", format: "buffer", cache: true}).response();
const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry);
return decryptedBuffer;
}
}

View file

@ -64,8 +64,11 @@ export class SecretStorage {
throw new Error("Bad MAC");
}
const plaintextBytes = await this._crypto.aes.decrypt(
aesKey, base64.decode(encryptedData.iv), ciphertextBytes);
const plaintextBytes = await this._crypto.aes.decryptCTR({
key: aesKey,
iv: base64.decode(encryptedData.iv),
data: ciphertextBytes
});
return textDecoder.decode(plaintextBytes);
}

View file

@ -27,6 +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 {BufferURL} from "./dom/BufferURL.js";
function addScript(src) {
return new Promise(function (resolve, reject) {
@ -127,4 +128,8 @@ export class Platform {
setNavigation(navigation) {
this._serviceWorkerHandler?.setNavigation(navigation);
}
createBufferURL(buffer, mimetype) {
return new BufferURL(buffer, mimetype);
}
}

View file

@ -0,0 +1,86 @@
/*
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.
*/
// WARNING: We have to be very careful about what mime-types we allow into blobs.
//
// This means that the content is rendered using the origin of the script which
// called createObjectURL(), and so if the content contains any scripting then it
// will pose a XSS vulnerability when the browser renders it. This is particularly
// bad if the user right-clicks the URI and pastes it into a new window or tab,
// as the blob will then execute with access to Element's full JS environment(!)
//
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
// for details.
//
// We mitigate this by only allowing mime-types into blobs which we know don't
// contain any scripting, and instantiate all others as application/octet-stream
// regardless of what mime-type the event claimed. Even if the payload itself
// is some malicious HTML, the fact we instantiate it with a media mimetype or
// application/octet-stream means the browser doesn't try to render it as such.
//
// One interesting edge case is image/svg+xml, which empirically *is* rendered
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
// *even if the mimetype is application/octet-stream*. However, empirically JS
// in the SVG isn't executed in this scenario, so we seem to be okay.
//
// Tested on Chrome 65 and Firefox 60
//
// The list below is taken mainly from
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
// events, so we pick the ones which HTML5 browsers should be able to display
//
// For the record, mime-types which must NEVER enter this list below include:
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
const ALLOWED_BLOB_MIMETYPES = {
'image/jpeg': true,
'image/gif': true,
'image/png': true,
'video/mp4': true,
'video/webm': true,
'video/ogg': true,
'audio/mp4': true,
'audio/webm': true,
'audio/aac': true,
'audio/mpeg': true,
'audio/ogg': true,
'audio/wave': true,
'audio/wav': true,
'audio/x-wav': true,
'audio/x-pn-wav': true,
'audio/flac': true,
'audio/x-flac': true,
};
export class BufferURL {
constructor(buffer, mimetype) {
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
mimetype = 'application/octet-stream';
}
const blob = new Blob([buffer], {type: mimetype});
this.url = URL.createObjectURL(blob);
}
dispose() {
URL.revokeObjectURL(this.url);
this.url = null;
}
}

View file

@ -159,21 +159,25 @@ class AESCrypto {
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {Object} jwkKey [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 AESCrypto {
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts,
aesKey,
ciphertext,
data,
), "decrypt");
return new Uint8Array(plaintext);
} catch (err) {
@ -196,6 +200,8 @@ class AESCrypto {
}
import base64 from "../../../../lib/base64-arraybuffer/index.js";
class AESLegacyCrypto {
constructor(aesjs) {
this._aesjs = aesjs;
@ -205,12 +211,31 @@ class AESLegacyCrypto {
* @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}) {
if (counterLength !== 64) {
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);
}
const aesjs = this._aesjs;
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));
}
}

View file

@ -51,14 +51,17 @@ class RequestResult {
}
export function createFetchRequest(createTimeout) {
return function fetchRequest(url, options) {
return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) {
const controller = typeof AbortController === "function" ? new AbortController() : null;
let options = {method, body};
if (controller) {
options = Object.assign(options, {
signal: controller.signal
});
}
url = addCacheBuster(url);
if (!cache) {
url = addCacheBuster(url);
}
options = Object.assign(options, {
mode: "cors",
credentials: "omit",
@ -76,18 +79,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 +112,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;

View file

@ -35,19 +35,24 @@ class RequestResult {
}
}
function send(url, options) {
function send(url, {method, headers, timeout, body, format}) {
const xhr = new XMLHttpRequest();
xhr.open(options.method, url);
if (options.headers) {
for(const [name, value] of options.headers.entries()) {
xhr.open(method, url);
if (format === "buffer") {
// important to call this after calling open
xhr.responseType = "arraybuffer";
}
if (headers) {
for(const [name, value] of headers.entries()) {
xhr.setRequestHeader(name, value);
}
}
if (options.timeout) {
xhr.timeout = options.timeout;
if (timeout) {
xhr.timeout = timeout;
}
xhr.send(options.body || null);
xhr.send(body || null);
return xhr;
}
@ -62,12 +67,17 @@ function xhrAsPromise(xhr, method, url) {
}
export function xhrRequest(url, options) {
url = addCacheBuster(url);
const {cache, format} = options;
if (!cache) {
url = addCacheBuster(url);
}
const xhr = send(url, options);
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => {
const {status} = xhr;
let body = null;
if (xhr.getResponseHeader("Content-Type") === "application/json") {
if (format === "buffer") {
body = xhr.response;
} else if (xhr.getResponseHeader("Content-Type") === "application/json") {
body = JSON.parse(xhr.responseText);
}
return {status, body};

View file

@ -505,6 +505,11 @@ ul.Timeline > li.messageStatus .message-container > p {
--avatar-size: 25px;
}
.message-container img.picture {
margin-top: 4px;
border-radius: 4px;
}
.TextMessageView.continuation .message-container {
margin-top: 0;
margin-bottom: 0;
@ -608,7 +613,7 @@ ul.Timeline > li.messageStatus .message-container > p {
flex: 0 0 200px;
}
.Settings .error {
.error {
color: red;
font-weight: 600;
}

View file

@ -144,6 +144,19 @@ export class ListView {
}
}
recreateItem(index, value) {
if (this._childInstances) {
const child = this._childCreator(value);
if (!child) {
this.onRemove(index, value);
} else {
const [oldChild] = this._childInstances.splice(index, 1, child);
this._root.replaceChild(child.mount(this._mountArgs), oldChild.root());
oldChild.unmount();
}
}
}
onBeforeListChanged() {}
onListChanged() {}
}

View file

@ -20,6 +20,17 @@ import {TextMessageView} from "./timeline/TextMessageView.js";
import {ImageView} from "./timeline/ImageView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
function viewClassForEntry(entry) {
switch (entry.shape) {
case "gap": return GapView;
case "announcement": return AnnouncementView;
case "message":
case "message-status":
return TextMessageView;
case "image": return ImageView;
}
}
export class TimelineList extends ListView {
constructor(viewModel) {
const options = {
@ -27,13 +38,9 @@ export class TimelineList extends ListView {
list: viewModel.tiles,
}
super(options, entry => {
switch (entry.shape) {
case "gap": return new GapView(entry);
case "announcement": return new AnnouncementView(entry);
case "message":
case "message-status":
return new TextMessageView(entry);
case "image": return new ImageView(entry);
const View = viewClassForEntry(entry);
if (View) {
return new View(entry);
}
});
this._atBottom = false;
@ -127,4 +134,21 @@ export class TimelineList extends ListView {
root.scrollTop = root.scrollHeight;
}
}
onUpdate(index, value, param) {
if (param === "shape") {
if (this._childInstances) {
const ExpectedClass = viewClassForEntry(value);
const child = this._childInstances[index];
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
// shape was updated, so we need to recreate the tile view,
// the shape parameter is set in EncryptedEventTile.updateEntry
// (and perhaps elsewhere by the time you read this)
super.recreateItem(index, value);
return;
}
}
}
super.onUpdate(index, value, param);
}
}

View file

@ -23,17 +23,19 @@ 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,
alt: vm => vm.label,
title: vm => vm.label,
});
const linkContainer = t.a({
href: vm.url,
target: "_blank",
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
}, image);
}, [
image,
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
]);
return renderMessage(t, vm,
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]