diff --git a/dist/bot.js b/dist/bot.js index 0086e19..30a20bf 100644 --- a/dist/bot.js +++ b/dist/bot.js @@ -23,6 +23,8 @@ var matrix = _interopRequireWildcard(require("matrix-js-sdk")); var _logger = _interopRequireDefault(require("./logger")); +var _encryptAttachment = _interopRequireDefault(require("./encrypt-attachment")); + global.Olm = require("olm"); class OcrccBot { @@ -100,7 +102,7 @@ class OcrccBot { await this.sendMessage(roomId, content); default: - _logger.default.log("error", `ERROR SENDING MESSAGE: ${err}`); + _logger.default.log("error", `ERROR SENDING MESSAGE ${content.body}: ${err}`); break; } @@ -289,28 +291,55 @@ class OcrccBot { this.sendTextMessage(roomId, "There is no transcript for this chat.", senderId); } - const filename = path.basename(transcriptFile) || "Transcript"; - const file = fs.readFileSync(transcriptFile); - const stats = fs.statSync(transcriptFile); - const url = await this.client.uploadContent(file, { - rawResponse: false, - name: filename - }); - - _logger.default.log('info', url); - - const content = { - msgtype: "m.file", - body: filename, - info: { - size: stats.size, + if (this.client.isRoomEncrypted(roomId)) { + let encryptInfo; + const filename = path.basename(transcriptFile) || "Transcript"; + const data = fs.readFileSync(transcriptFile); + const encryptResult = await _encryptAttachment.default.encryptAttachment(data); + const buffer = Buffer.from(encryptResult.data); + encryptInfo = encryptResult.info; + const url = await this.client.uploadContent(buffer, { + rawResponse: false, + name: filename + }); + encryptInfo.url = url.content_uri; + encryptInfo.mimetype = 'text/plain'; + const content = { + msgtype: "m.file", + body: filename, + info: { + mimetype: 'text/plain' + }, + file: encryptInfo, + url: url.content_uri, + showToUser: senderId, mimetype: 'text/plain' - }, - url: url.content_uri, - showToUser: senderId, - mimetype: 'text/plain' - }; - this.sendMessage(roomId, content); + }; + this.sendMessage(roomId, content); + } else { + const filename = path.basename(transcriptFile) || "Transcript"; + const file = fs.readFileSync(transcriptFile); + const stats = fs.statSync(transcriptFile); + const url = await this.client.uploadContent(file, { + rawResponse: false, + name: filename + }); + + _logger.default.log('info', url); + + const content = { + msgtype: "m.file", + body: filename, + info: { + size: stats.size, + mimetype: 'text/plain' + }, + url: url.content_uri, + showToUser: senderId, + mimetype: 'text/plain' + }; + this.sendMessage(roomId, content); + } } catch (err) { _logger.default.log("error", `ERROR UPLOADING CONTENT: ${err}`); @@ -455,25 +484,30 @@ class OcrccBot { } if (member.membership === "leave" && member.userId !== this.config.BOT_USERID) { - const facilitatorId = this.localStorage.getItem(`${member.roomId}-facilitator`); - - if (member.userId === facilitatorId) { - this.sendTextMessage(member.roomId, `${member.name} has left the chat.`); - } // leave if there is nobody in the room - - + // ensure bot is still in the room + const roomData = await this.client.getJoinedRooms(); + const joinedRooms = roomData["joined_rooms"]; + const isBotInRoom = joinedRooms.includes(member.roomId); const room = this.client.getRoom(member.roomId); - if (!room) return; + if (!room) return; // leave if there is nobody in the room + const memberCount = room.getJoinedMemberCount(); - if (memberCount === 1) { + if (memberCount === 1 && isBotInRoom) { // just the bot left _logger.default.log("info", `LEAVING EMPTY ROOM ==> ${member.roomId}`); this.deleteTranscript(member.userId, member.roomId); this.localStorage.removeItem(`${member.roomId}-facilitator`); this.localStorage.removeItem(`${member.roomId}-transcript`); - this.client.leave(member.roomId); + return this.client.leave(member.roomId); + } // notify room if the facilitator has left + + + const facilitatorId = this.localStorage.getItem(`${member.roomId}-facilitator`); + + if (isBotInRoom && member.userId === facilitatorId) { + this.sendTextMessage(member.roomId, `${member.name} has left the chat.`); } } }); @@ -531,14 +565,20 @@ class OcrccBot { sessionStore: new matrix.WebStorageSessionStore(localStorage) }; this.client = matrix.createClient(opts); - await this.deleteOldDevices(); - await this.trackJoinedRooms(); await this.client.initCrypto(); - await this.setMembershipListeners(); - await this.setMessageListeners(); this.client.startClient({ initialSyncLimit: 0 }); + this.client.once('sync', async (state, prevState, data) => { + _logger.default.log("info", `SYNC STATUS: ${state}`); + + if (state === 'PREPARED') { + await this.deleteOldDevices(); + await this.trackJoinedRooms(); + await this.setMembershipListeners(); + await this.setMessageListeners(); + } + }); } catch (err) { this.handleBotCrash(undefined, err); diff --git a/dist/encrypt-attachment.js b/dist/encrypt-attachment.js new file mode 100644 index 0000000..9ce9050 --- /dev/null +++ b/dist/encrypt-attachment.js @@ -0,0 +1,173 @@ +"use strict"; + +// this is from https://github.com/matrix-org/browser-encrypt-attachment +// which is the library used by matrix-reack-sdk to encrypt and decrypt attachments +// just dropped in node-webcrypto-ossl to replace window.crypto +// and Buffer for base64 encoding/decoding instead of window.btoa/window.atob + +/** + * Encrypt an attachment. + * @param {ArrayBuffer} plaintextBuffer The attachment data buffer. + * @return {Promise} A promise that resolves with an object when the attachment is encrypted. + * The object has a "data" key with an ArrayBuffer of encrypted data and an "info" key + * with an object containing the info needed to decrypt the data. + */ +const { + Crypto +} = require("node-webcrypto-ossl"); + +const crypto = new Crypto(); + +function encryptAttachment(plaintextBuffer) { + var cryptoKey; // The AES key object. + + var exportedKey; // The AES key exported as JWK. + + var ciphertextBuffer; // ArrayBuffer of encrypted data. + + var sha256Buffer; // ArrayBuffer of digest. + + var ivArray; // Uint8Array of AES IV + // Generate an IV where the first 8 bytes are random and the high 8 bytes + // are zero. We set the counter low bits to 0 since it makes it unlikely + // that the 64 bit counter will overflow. + + ivArray = new Uint8Array(16); + crypto.getRandomValues(ivArray.subarray(0, 8)); // Load the encryption key. + + return crypto.subtle.generateKey({ + "name": "AES-CTR", + length: 256 + }, true, ["encrypt", "decrypt"]).then(function (generateKeyResult) { + cryptoKey = generateKeyResult; // Export the Key as JWK. + + return crypto.subtle.exportKey("jwk", cryptoKey); + }).then(function (exportKeyResult) { + exportedKey = exportKeyResult; // Encrypt the input ArrayBuffer. + // Use half of the iv as the counter by setting the "length" to 64. + + return crypto.subtle.encrypt({ + name: "AES-CTR", + counter: ivArray, + length: 64 + }, cryptoKey, plaintextBuffer); + }).then(function (encryptResult) { + ciphertextBuffer = encryptResult; // SHA-256 the encrypted data. + + return crypto.subtle.digest("SHA-256", ciphertextBuffer); + }).then(function (digestResult) { + sha256Buffer = digestResult; + return { + data: ciphertextBuffer, + info: { + v: "v2", + key: exportedKey, + iv: encodeBase64(ivArray), + hashes: { + sha256: encodeBase64(new Uint8Array(sha256Buffer)) + } + } + }; + }); +} +/** + * 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. + */ + + +function decryptAttachment(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 cryptoKey; // The AES key object. + + var ivArray = decodeBase64(info.iv); + var expectedSha256base64 = info.hashes.sha256; // Load the AES from the "key" key of the info object. + + return crypto.subtle.importKey("jwk", info.key, { + "name": "AES-CTR" + }, false, ["encrypt", "decrypt"]).then(function (importKeyResult) { + cryptoKey = importKeyResult; // Check the sha256 hash + + return crypto.subtle.digest("SHA-256", ciphertextBuffer); + }).then(function (digestResult) { + if (encodeBase64(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; + } + + return crypto.subtle.decrypt({ + name: "AES-CTR", + counter: ivArray, + length: counterLength + }, cryptoKey, ciphertextBuffer); + }); +} +/** + * Encode a typed array of uint8 as base64. + * @param {Uint8Array} uint8Array The data to encode. + * @return {string} The base64 without padding. + */ + + +function encodeBase64(uint8Array) { + // Misinterpt the Uint8Array as Latin-1. + // window.btoa expects a unicode string with codepoints in the range 0-255. + var latin1String = String.fromCharCode.apply(null, uint8Array); // Use the builtin base64 encoder. + // var paddedBase64 = window.btoa(latin1String); + + var paddedBase64 = Buffer.from(latin1String, 'binary').toString('base64'); // Calculate the unpadded length. + + var inputLength = uint8Array.length; + var outputLength = 4 * Math.floor((inputLength + 2) / 3) + (inputLength + 2) % 3 - 2; // Return the unpadded base64. + + return paddedBase64.slice(0, outputLength); +} +/** + * Decode a base64 string to a typed array of uint8. + * This will decode unpadded base64, but will also accept base64 with padding. + * @param {string} base64 The unpadded base64 to decode. + * @return {Uint8Array} The decoded data. + */ + + +function decodeBase64(base64) { + // Pad the base64 up to the next multiple of 4. + var paddedBase64 = base64 + "===".slice(0, (4 - base64.length % 4) % 4); // Decode the base64 as a misinterpreted Latin-1 string. + // window.atob returns a unicode string with codepoints in the range 0-255. + // var latin1String = window.atob(paddedBase64); + + var latin1String = Buffer.from(paddedBase64, 'base64').toString('binary'); // Encode the string as a Uint8Array as Latin-1. + + var uint8Array = new Uint8Array(latin1String.length); + + for (var i = 0; i < latin1String.length; i++) { + uint8Array[i] = latin1String.charCodeAt(i); + } + + return uint8Array; +} + +try { + exports.encryptAttachment = encryptAttachment; + exports.decryptAttachment = decryptAttachment; +} catch (e) {// Ignore unknown variable "exports" errors when this is loaded directly into a browser + // This means that we can test it without having to use browserify. + // The intention is that the library is used using browserify. +} \ No newline at end of file diff --git a/src/encrypt-attachment.js b/src/encrypt-attachment.js index 51e3543..9b8f6d4 100644 --- a/src/encrypt-attachment.js +++ b/src/encrypt-attachment.js @@ -1,15 +1,9 @@ -const { Crypto } = require("node-webcrypto-ossl"); - -const crypto = new Crypto(); - - // this is from https://github.com/matrix-org/browser-encrypt-attachment // which is the library used by matrix-reack-sdk to encrypt and decrypt attachments // just dropped in node-webcrypto-ossl to replace window.crypto // and Buffer for base64 encoding/decoding instead of window.btoa/window.atob - /** * Encrypt an attachment. * @param {ArrayBuffer} plaintextBuffer The attachment data buffer. @@ -17,6 +11,10 @@ const crypto = new Crypto(); * The object has a "data" key with an ArrayBuffer of encrypted data and an "info" key * with an object containing the info needed to decrypt the data. */ + +const { Crypto } = require("node-webcrypto-ossl"); +const crypto = new Crypto(); + function encryptAttachment(plaintextBuffer) { var cryptoKey; // The AES key object. var exportedKey; // The AES key exported as JWK.