diff --git a/package-lock.json b/package-lock.json index 154d5fc..a0e75f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "axios": "^0.21.0", "core-js": "^3.6.5", "intersection-observer": "^0.11.0", + "js-sha256": "^0.9.0", "json-web-key": "^0.4.0", "material-design-icons-iconfont": "^5.0.1", "matrix-js-sdk": "^9.0.1", @@ -7903,6 +7904,11 @@ "node": ">=1.0.0" } }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -21073,6 +21079,11 @@ "easy-stack": "^1.0.1" } }, + "js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index f17d1d4..0c68e04 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "axios": "^0.21.0", "core-js": "^3.6.5", "intersection-observer": "^0.11.0", + "js-sha256": "^0.9.0", "json-web-key": "^0.4.0", "material-design-icons-iconfont": "^5.0.1", "matrix-js-sdk": "^9.0.1", diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 063291c..4b94fb0 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -65,10 +65,10 @@ - Cancel - Send + Send @@ -89,6 +89,7 @@ import RoomAvatarChanged from "./messages/RoomAvatarChanged.vue"; import DebugEvent from "./messages/DebugEvent.vue"; import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue"; import MessageIncomingImage from "./messages/MessageIncomingImage.vue"; +import util from "../plugins/utils"; // from https://kirbysayshi.com/2013/08/19/maintaining-scroll-position-knockoutjs-list.html function ScrollPosition(node) { @@ -211,7 +212,9 @@ export default { ); this.timelineWindow.load(null, 20).then(() => { this.events = this.timelineWindow.getEvents(); - this.paginateBackIfNeeded(); + this.$nextTick(() => { + this.paginateBackIfNeeded(); + }); }); }, }, @@ -316,7 +319,13 @@ export default { sendMessage() { if (this.currentInput.length > 0) { - this.sendMatrixMessage(this.currentInput); + util.sendTextMessage(this.$matrix.matrixClient, this.roomId, this.currentInput) + .then(() => { + console.log("Sent message"); + }) + .catch(err => { + console.log("Failed to send:", err); + }) this.currentInput = ""; } }, @@ -346,87 +355,27 @@ export default { sendAttachment() { if (this.currentImageInputPath) { - const opts = { - progressHandler: this.onUploadProgress, - }; - this.currentSendOperation = this.$matrix.uploadFile( - this.currentImageInputPath, - opts - ); - var matrixUri; + this.currentSendProgress = 0; + this.currentSendOperation = util.sendEncyptedImage(this.$matrix.matrixClient, this.roomId, this.currentImageInputPath, this.onUploadProgress); this.currentSendOperation - .then((uri) => { - matrixUri = uri; - return this.$matrix.matrixClient.sendImageMessage( - this.roomId, - matrixUri, - "", - "Image", - null - ); - }) - .then((result) => { - console.log("Image sent: ", result); - }) - .catch((err) => { - console.log("Image send error: ", err); - if (err && err.name == "UnknownDeviceError") { - console.log("Unknown devices. Mark as known before retrying."); - var setAsKnownPromises = []; - for (var user of Object.keys(err.devices)) { - const userDevices = err.devices[user]; - for (var deviceId of Object.keys(userDevices)) { - const deviceInfo = userDevices[deviceId]; - if (!deviceInfo.known) { - setAsKnownPromises.push( - this.$matrix.matrixClient.setDeviceKnown( - user, - deviceId, - true - ) - ); - } - } - } - Promise.all(setAsKnownPromises) - .then(() => { - // All devices now marked as "known", try to resend - return this.$matrix.matrixClient.sendImageMessage( - this.roomId, - matrixUri, - "", - "Image", - null - ); - }) - .then((result) => { - console.log("Image sent: ", result); - }) - .catch((err) => { - // Still error, abort - this.currentSendError = err.toLocaleString(); - }); - } else { - this.currentSendError = err.toLocaleString(); - } - }); + .then(() => { + this.currentSendOperation = null; + this.currentImageInput = null; + this.currentSendProgress = 0; + }) + .catch(err => { + this.currentSendError = err.toLocaleString(); + this.currentSendOperation = null; + this.currentSendProgress = 0; + }); } }, - sendMatrixMessage(body) { - var content = { - body: body, - msgtype: "m.text", - }; - this.$matrix.matrixClient.sendEvent( - this.roomId, - "m.room.message", - content, - "", - (err, ignoredres) => { - console.log(err); - } - ); + cancelSendAttachment() { + if (this.currentSendOperation) { + this.currentSendOperation.reject("Canceled"); + } + this.currentImageInput = null; }, handleScrolledToTop() { diff --git a/src/components/messages/MessageIncomingImage.vue b/src/components/messages/MessageIncomingImage.vue index d04ea8a..d259ce3 100644 --- a/src/components/messages/MessageIncomingImage.vue +++ b/src/components/messages/MessageIncomingImage.vue @@ -23,7 +23,6 @@ diff --git a/src/plugins/utils.js b/src/plugins/utils.js index c2d2640..96681af 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -1,9 +1,18 @@ import axios from 'axios'; +import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers"; +var sha256 = require('js-sha256').sha256; +var aesjs = require('aes-js'); +var base64Url = require('json-web-key/lib/base64url'); class Util { getThumbnail(matrixClient, event, ignoredw, ignoredh) { return new Promise((resolve, reject) => { const content = event.getContent(); + if (content.url != null) { + // Unencrypted, just return! + resolve(matrixClient.mxcUrlToHttp(content.url)); + return; + } var url = null; var file = null; if ( @@ -38,13 +47,20 @@ class Util { axios.get(url, { responseType: 'arraybuffer' }) .then(response => { return new Promise((resolve, ignoredReject) => { - var aesjs = require('aes-js'); - var base64Url = require('json-web-key/lib/base64url'); var key = base64Url.decode(file.key.k); var iv = base64Url.decode(file.iv); var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv)); const data = new Uint8Array(response.data); + + // Calculate sha256 and compare hashes + var hash = new Uint8Array(sha256.create().update(data).arrayBuffer()); + const originalHash = base64Url.decode(file.hashes.sha256); + if (Buffer.compare(Buffer.from(hash), Buffer.from(originalHash.buffer)) != 0) { + reject("Hashes don't match!"); + return; + } + var decryptedBytes = aesCtr.decrypt(data); resolve(decryptedBytes); }); @@ -58,6 +74,130 @@ class Util { }); }); } + + sendTextMessage(matrixClient, roomId, text) { + return this.sendMessage(matrixClient, roomId, ContentHelpers.makeTextMessage(text)); + } + + sendMessage(matrixClient, roomId, content) { + return new Promise((resolve, reject) => { + matrixClient.sendMessage(roomId, content, undefined, undefined) + .then((result) => { + console.log("Message sent: ", result); + resolve(true); + }) + .catch(err => { + console.log("Image send error: ", err); + if (err && err.name == "UnknownDeviceError") { + console.log("Unknown devices. Mark as known before retrying."); + var setAsKnownPromises = []; + for (var user of Object.keys(err.devices)) { + const userDevices = err.devices[user]; + for (var deviceId of Object.keys(userDevices)) { + const deviceInfo = userDevices[deviceId]; + if (!deviceInfo.known) { + setAsKnownPromises.push( + matrixClient.setDeviceKnown( + user, + deviceId, + true + ) + ); + } + } + } + Promise.all(setAsKnownPromises) + .then(() => { + // All devices now marked as "known", try to resend + matrixClient.resendEvent(err.event) + .then((result) => { + console.log("Message sent: ", result); + resolve(true); + }) + .catch((err) => { + // Still error, abort + reject(err.toLocaleString()); + }); + }); + } + else { + reject(err.toLocaleString()); + } + }); + }); + } + + sendEncyptedImage(matrixClient, roomId, file, onUploadProgress) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = (e) => { + const fileContents = e.target.result; + const crypto = require('crypto'); + let key = crypto.randomBytes(256 / 8); + let iv = Buffer.concat([crypto.randomBytes(8),Buffer.alloc(8)]); // Initialization vector. + + // Encrypt + var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv)); + const data = new Uint8Array(fileContents); + var encryptedBytes = aesCtr.encrypt(data); + + // Calculate sha256 + var hash = new Uint8Array(sha256.create().update(encryptedBytes).arrayBuffer()); + + const jwk = { + kty: 'oct', + key_opts: ['encrypt', 'decrypt'], + alg: 'A256CTR', + k: base64Url.encode(key), + ext: true + }; + + const encryptedFile = { + mimetype: file.type, + key: jwk, + iv: Buffer.from(iv).toString('base64').replace( /=/g, '' ), + hashes: { sha256: Buffer.from(hash).toString('base64').replace( /=/g, '' )}, + v: 'v2' + }; + console.log("Encrypted file:", encryptedFile); + + const info = { + mimetype: file.type, + size: file.size + }; + + + const opts = { + type: file.type, + name: 'Image', + progressHandler: onUploadProgress, + }; + + const messageContent = { + body: 'Image', + file: encryptedFile, + info: info, + msgtype: 'm.image' + } + + matrixClient.uploadContent(encryptedBytes, opts) + .then((uri) => { + encryptedFile.url = uri; + return this.sendMessage(matrixClient, roomId, messageContent) + }) + .then(result => { + resolve(result); + }) + .catch(err => { + reject(err); + }); + } + reader.onerror = (err) => { + reject(err); + } + reader.readAsArrayBuffer(file); + }); + } } export default new Util();