basic PoC of image decryption working

needs looooaaads of cleanup still
This commit is contained in:
Bruno Windels 2020-10-23 17:18:11 +02:00
parent 8125173420
commit 3a6268f0c1
9 changed files with 109 additions and 31 deletions

View file

@ -95,6 +95,9 @@ export class TilesCollection extends BaseObservableList {
onUnsubscribeLast() { onUnsubscribeLast() {
this._entrySubscription = this._entrySubscription(); this._entrySubscription = this._entrySubscription();
for(let i = 0; i < this._tiles.length; i+= 1) {
this._tiles[i].dispose();
}
this._tiles = null; this._tiles = null;
} }

View file

@ -20,20 +20,47 @@ const MAX_HEIGHT = 300;
const MAX_WIDTH = 400; const MAX_WIDTH = 400;
export class ImageTile extends MessageTile { 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() { get thumbnailUrl() {
if (this._decryptedUrl) {
return this._decryptedUrl;
}
const mxcUrl = this._getContent()?.url; const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") { if (typeof mxcUrl === "string") {
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
} }
return null; return "";
} }
get url() { get url() {
if (this._decryptedUrl) {
return this._decryptedUrl;
}
const mxcUrl = this._getContent()?.url; const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") { if (typeof mxcUrl === "string") {
return this._mediaRepository.mxcUrl(mxcUrl); return this._mediaRepository.mxcUrl(mxcUrl);
} }
return null; return "";
} }
_scaleFactor() { _scaleFactor() {
@ -62,4 +89,12 @@ export class ImageTile extends MessageTile {
get shape() { get shape() {
return "image"; return "image";
} }
dispose() {
if (this._decryptedUrl) {
URL.revokeObjectURL(this._decryptedUrl);
this._decryptedUrl = null;
}
super.dispose();
}
} }

View file

@ -168,6 +168,11 @@ export class SessionContainer {
} }
this._requestScheduler = new RequestScheduler({hsApi, clock: this._clock}); this._requestScheduler = new RequestScheduler({hsApi, clock: this._clock});
this._requestScheduler.start(); this._requestScheduler.start();
const mediaRepository = new MediaRepository({
homeServer: sessionInfo.homeServer,
cryptoDriver: this._cryptoDriver,
request: this._request,
});
this._session = new Session({ this._session = new Session({
storage: this._storage, storage: this._storage,
sessionInfo: filteredSessionInfo, sessionInfo: filteredSessionInfo,
@ -176,7 +181,7 @@ export class SessionContainer {
clock: this._clock, clock: this._clock,
olmWorker, olmWorker,
cryptoDriver: this._cryptoDriver, cryptoDriver: this._cryptoDriver,
mediaRepository: new MediaRepository(sessionInfo.homeServer) mediaRepository
}); });
await this._session.load(); await this._session.load();
if (isNewLogin) { if (isNewLogin) {

View file

@ -75,7 +75,8 @@ export class HomeServerApi {
method, method,
headers, headers,
body: bodyString, body: bodyString,
timeout: options?.timeout timeout: options?.timeout,
format: "json"
}); });
const wrapper = new RequestWrapper(method, url, requestResult); const wrapper = new RequestWrapper(method, url, requestResult);

View file

@ -15,17 +15,20 @@ limitations under the License.
*/ */
import {encodeQueryParams} from "./common.js"; import {encodeQueryParams} from "./common.js";
import {decryptAttachment} from "../e2ee/attachment.js";
export class MediaRepository { export class MediaRepository {
constructor(homeserver) { constructor({homeServer, cryptoDriver, request}) {
this._homeserver = homeserver; this._homeServer = homeServer;
this._cryptoDriver = cryptoDriver;
this._request = request;
} }
mxcUrlThumbnail(url, width, height, method) { mxcUrlThumbnail(url, width, height, method) {
const parts = this._parseMxcUrl(url); const parts = this._parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = 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 httpUrl + "?" + encodeQueryParams({width, height, method});
} }
return null; return null;
@ -35,7 +38,7 @@ export class MediaRepository {
const parts = this._parseMxcUrl(url); const parts = this._parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = 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 { } else {
return null; return null;
} }
@ -49,4 +52,11 @@ export class MediaRepository {
return null; 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;
}
} }

View file

@ -51,8 +51,9 @@ class RequestResult {
} }
export function createFetchRequest(createTimeout) { 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; const controller = typeof AbortController === "function" ? new AbortController() : null;
let options = {method, body};
if (controller) { if (controller) {
options = Object.assign(options, { options = Object.assign(options, {
signal: controller.signal signal: controller.signal
@ -76,18 +77,22 @@ export function createFetchRequest(createTimeout) {
// cache: "no-store", // cache: "no-store",
cache: "default", cache: "default",
}); });
if (options.headers) { if (headers) {
const headers = new Headers(); const fetchHeaders = new Headers();
for(const [name, value] of options.headers.entries()) { for(const [name, value] of headers.entries()) {
headers.append(name, value); fetchHeaders.append(name, value);
} }
options.headers = headers; options.headers = fetchHeaders;
} }
const promise = fetch(url, options).then(async response => { const promise = fetch(url, options).then(async response => {
const {status} = response; const {status} = response;
let body; let body;
try { try {
body = await response.json(); if (format === "json") {
body = await response.json();
} else if (format === "buffer") {
body = await response.arrayBuffer();
}
} catch (err) { } catch (err) {
// some error pages return html instead of json, ignore error // some error pages return html instead of json, ignore error
if (!(err.name === "SyntaxError" && status >= 400)) { if (!(err.name === "SyntaxError" && status >= 400)) {
@ -105,14 +110,14 @@ export function createFetchRequest(createTimeout) {
// //
// One could check navigator.onLine to rule out the first // One could check navigator.onLine to rule out the first
// but the 2 latter ones are indistinguishable from javascript. // 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; throw err;
}); });
const result = new RequestResult(promise, controller); const result = new RequestResult(promise, controller);
if (options.timeout) { if (timeout) {
result.promise = abortOnTimeout(createTimeout, options.timeout, result, result.promise); result.promise = abortOnTimeout(createTimeout, timeout, result, result.promise);
} }
return result; return result;

View file

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

View file

@ -158,22 +158,26 @@ class CryptoAESDriver {
} }
/** /**
* [decrypt description] * [decrypt description]
* @param {BufferSource} key [description] * @param {string} keyFormat "raw" or "jwk"
* @param {BufferSource | Object} key [description]
* @param {BufferSource} iv [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] * @return {BufferSource} [description]
*/ */
async decrypt(key, iv, ciphertext) { async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) {
const opts = { const opts = {
name: "AES-CTR", name: "AES-CTR",
counter: iv, counter: iv,
length: 64, length: counterLength,
}; };
let aesKey; let aesKey;
try { try {
const selectedKey = key || jwkKey;
const format = jwkKey ? "jwk" : "raw";
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey( aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw', format,
key, selectedKey,
opts, opts,
false, false,
['decrypt'], ['decrypt'],
@ -186,7 +190,7 @@ class CryptoAESDriver {
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams // see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts, opts,
aesKey, aesKey,
ciphertext, data,
), "decrypt"); ), "decrypt");
return new Uint8Array(plaintext); return new Uint8Array(plaintext);
} catch (err) { } catch (err) {
@ -205,12 +209,24 @@ class CryptoLegacyAESDriver {
* @param {BufferSource} key [description] * @param {BufferSource} key [description]
* @param {BufferSource} iv [description] * @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description] * @param {BufferSource} ciphertext [description]
* @param {Number} counterLength the size of the counter, in bits
* @return {BufferSource} [description] * @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; 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))); 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

@ -23,14 +23,14 @@ export class ImageView extends TemplateView {
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
const image = t.img({ const image = t.img({
className: "picture", className: "picture",
src: vm.thumbnailUrl, src: vm => vm.thumbnailUrl,
width: vm.thumbnailWidth, width: vm.thumbnailWidth,
height: vm.thumbnailHeight, height: vm.thumbnailHeight,
loading: "lazy", loading: "lazy",
alt: vm.label, alt: vm.label,
}); });
const linkContainer = t.a({ const linkContainer = t.a({
href: vm.url, href: vm => vm.url,
target: "_blank", target: "_blank",
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;` style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
}, image); }, image);