diff --git a/js/cryptoLib/crypto.js b/js/cryptoLib/crypto.js new file mode 100644 index 00000000..8e1b92cf --- /dev/null +++ b/js/cryptoLib/crypto.js @@ -0,0 +1,208 @@ +/** + * AES GCM Stream + * This module exports encrypt and decrypt stream constructors which can be + * used to protect data with authenticated encryption. + */ +'use strict'; + +let stream = require('stream'); +let Transform = stream.Transform; +let util = require('util'); +let crypto = require('crypto'); +let forge = require('node-forge'); + +let KEY_LENGTH = 32; // bytes +let GCM_NONCE_LENGTH = 12; //bytes +let GCM_MAC_LENGTH = 16; //bytes + +let keyEncoding = 'base64'; + +/** + * Private helper method to validate a key passed into the Encrypt and Decrypt streams. + * Strings are converted it into a buffer, buffers are returned as they are. + * @param key + * @throws Missing, Encoding, or Length errors + * @returns Buffer + */ +let validateAndConvertKey = function(key) { + if (key && key instanceof Buffer && key.length === KEY_LENGTH) { + return key; + } else if (key && typeof key === 'string') { + // This is for temporary purpose only. Will be retrieving the key from the backend + // Todo: remove node-forge + let md = forge.md.sha256.create(); + md.update(key); + let bufKey = new Buffer(bits2b64(md.digest().getBytes()), keyEncoding); + if (bufKey.length !== KEY_LENGTH) { + let encodingErrorMessage = 'Provided key string is either of an unknown encoding (expected: ' + + keyEncoding + ') or the wrong length.'; + throw new Error(encodingErrorMessage); + } + return bufKey; + } + let message = 'The key options property is required! Expected ' + + keyEncoding + ' encoded string or a buffer.'; + throw new Error(message); +}; + +/** + * encode bits to b64 + * @param bits + * @returns {*} + */ +let bits2b64 = function (bits) { + return forge.util.encode64(bits); +}; + +exports.encrypt = EncryptionStream; +exports.decrypt = DecryptionStream; + + +/** + * createSalt + * Helper method that returns a salt + * @returns string + * @throws error + */ +exports.createSalt = function(length) { + try { + return crypto.randomBytes(length); + } catch (ex) { + console.error('Problem reading random data and generating salt!'); + throw ex; + } +}; + +/** + * EncryptionStream + * A constructor which returns an encryption stream + * The stream first outputs a 12 byte nonce then encrypted cipher text. + * When the stream is flushed it outputs a 16 byte MAC. + * @param options Object Object.key is the only required param + * @returns {EncryptionStream} + * @constructor + */ +function EncryptionStream(options) { + if (!(this instanceof EncryptionStream)) { + return new EncryptionStream(options); + } + + let nonce = options.nonce || exports.createSalt(12); + + this._key = validateAndConvertKey(options.key); + this._cipher = crypto.createCipheriv('aes-256-gcm', this._key, nonce); + + Transform.call(this, options); + this.push(nonce); +} +util.inherits(EncryptionStream, Transform); + +EncryptionStream.prototype._transform = function(chunk, enc, cb) { + this.push(this._cipher.update(chunk)); + cb(); +}; + +EncryptionStream.prototype._flush = function(cb) { + // final must be called on the cipher before generating a MAC + this._cipher.final(); // this will never output data + this.push(this._cipher.getAuthTag()); // 16 bytes + + cb(); +}; + +/** + * DecryptionStream + * A constructor which returns a decryption stream + * The stream assumes the first 12 bytes of data are the nonce and the final + * 16 bytes received is the MAC. + * @param options Object Object.key is the only required param + * @returns {DecryptionStream} + * @constructor + */ +function DecryptionStream(options) { + if (!(this instanceof DecryptionStream)) { + return new DecryptionStream(options); + } + + this._started = false; + this._nonce = new Buffer(GCM_NONCE_LENGTH); + this._nonceBytesRead = 0; + this._cipherTextChunks = []; + this._key = validateAndConvertKey(options.key); + + Transform.call(this, options); +} +util.inherits(DecryptionStream, Transform); + +DecryptionStream.prototype._transform = function(chunk, enc, cb) { + let chunkLength = chunk.length; + let chunkOffset = 0; + let _chunk = chunk; + if (!this._started) { + if (this._nonceBytesRead < GCM_NONCE_LENGTH) { + let nonceRemaining = GCM_NONCE_LENGTH - this._nonceBytesRead; + chunkOffset = chunkLength <= nonceRemaining ? chunkLength : nonceRemaining; + _chunk.copy(this._nonce, this._nonceBytesRead, 0, chunkOffset); + _chunk = _chunk.slice(chunkOffset); + chunkLength = _chunk.length; + this._nonceBytesRead += chunkOffset; + } + + + if (this._nonceBytesRead === GCM_NONCE_LENGTH) { + this._decipher = crypto.createDecipheriv('aes-256-gcm', this._key, this._nonce); + this._started = true; + } + } + + // We can't use an else because we have no idea how long our chunks will be + // all we know is that once we've got a nonce we can start storing cipher text + if (this._started) { + this._cipherTextChunks.push(_chunk); + } + + cb(); +}; + +DecryptionStream.prototype._flush = function(cb) { + let mac = pullOutMac(this._cipherTextChunks); + if (!mac) { + return this.emit('error', new Error('Decryption failed: bad cipher text.')); + } + this._decipher.setAuthTag(mac); + let decrypted = this._cipherTextChunks.map(function(item) { + return this._decipher.update(item); + }, this); + try { + this._decipher.final(); + } catch (e) { + return cb(e); + } + decrypted.forEach(function(item) { + this.push(item); + }, this); + return cb(); +}; + +function pullOutMac(array) { + let macBits = []; + let macByteCount = 0; + let current, macStartIndex; + while (macByteCount !== GCM_MAC_LENGTH && array.length) { + current = array.pop(); + if (macByteCount + current.length <= GCM_MAC_LENGTH) { + macBits.push(current); + macByteCount += current.length; + } else { + macStartIndex = (macByteCount + current.length) - GCM_MAC_LENGTH; + macBits.push(current.slice(macStartIndex)); + array.push(current.slice(0, macStartIndex)); + macByteCount += (current.length - macStartIndex); + } + } + if (macByteCount !== GCM_MAC_LENGTH) { + return null; + } + macBits.reverse(); + return Buffer.concat(macBits, GCM_MAC_LENGTH); +} \ No newline at end of file diff --git a/js/cryptoLib/index.js b/js/cryptoLib/index.js new file mode 100644 index 00000000..f05ed8ee --- /dev/null +++ b/js/cryptoLib/index.js @@ -0,0 +1,178 @@ +'use strict'; +const electron = require('electron'); +const app = electron.app; +const path = require('path'); +const fs = require('fs'); +const archiver = require('archiver'); +const zipArchive = archiver('zip'); +const extract = require('extract-zip'); +const isDevEnv = require('../utils/misc.js').isDevEnv; +const crypto = require('./crypto'); + +const userData = path.join(app.getPath('userData')); +const INDEX_DATA_FOLDER = isDevEnv ? './data/search_index' : path.join(userData, 'data/search_index'); +const TEMPORARY_PATH = isDevEnv ? path.join(__dirname, '..', '..') : userData; + +class Crypto { + + // TODO: Need to pass key for encryption and decryption + constructor() { + + // will be handling after implementing in client app + let userId = 'user_data'; + let INDEX_VERSION = 'v1'; + // will be handling after implementing in client app + + this.indexDataFolder = INDEX_DATA_FOLDER + '_' + userId + '_' + INDEX_VERSION; + this.permanentIndexFolderName = 'search_index_' + userId + '_' + INDEX_VERSION; + this.dump = TEMPORARY_PATH; + this.extractToPath = `${TEMPORARY_PATH}/data/${this.permanentIndexFolderName}`; + this.key = "XrwVgWR4czB1a9scwvgRUNbXiN3W0oWq7oUBenyq7bo="; // temporary only + this.encryptedIndex = `${INDEX_DATA_FOLDER + '_' + userId + '_' + INDEX_VERSION}.enc`; + this.zipErrored = false; + } + + /** + * Creates a zip of the data folder and encrypting + * removing the data folder and the dump files + * @returns {Promise} + */ + encryption() { + return new Promise((resolve, reject) => { + + if (!fs.existsSync(this.indexDataFolder)){ + // will be handling after implementing in client app + reject(); + return; + } + + let output = fs.createWriteStream(`${this.dump}/${this.permanentIndexFolderName}.zip`); + + + zipArchive.on('end', () => { + + if (!fs.existsSync(`${this.dump}/${this.permanentIndexFolderName}.zip`)){ + // will be handling after implementing in client app + reject(); + return; + } + + const input = fs.createReadStream(`${this.dump}/${this.permanentIndexFolderName}.zip`); + const outputEncryption = fs.createWriteStream(this.encryptedIndex); + let config = { + key: this.key + }; + const encrypt = crypto.encrypt(config); + + input.pipe(encrypt).pipe(outputEncryption).on('finish', (err, res) => { + if (err) { + reject(new Error(err)); + } + if (!this.zipErrored) { + fs.unlinkSync(`${this.dump}/${this.permanentIndexFolderName}.zip`); + Crypto.deleteFolderRecursive(this.indexDataFolder) + .then(function () { + resolve(res); + }) + .catch(function (error) { + reject(new Error(error)) + }); + } + }); + }); + + zipArchive.pipe(output); + + zipArchive.directory(this.indexDataFolder + '/', false); + + zipArchive.finalize((err) => { + if (err) { + this.zipErrored = true; + reject(new Error(err)); + } + }); + }); + } + + /** + * Decrypting the .enc file and unzipping + * removing the .enc file and the dump files + * @returns {Promise} + */ + decryption() { + return new Promise((resolve, reject) => { + + if (!fs.existsSync(this.encryptedIndex)){ + // will be handling after implementing in client app + reject(); + return; + } + + const input = fs.createReadStream(this.encryptedIndex); + const output = fs.createWriteStream(`${this.dump}/decrypted.zip`); + let config = { + key: this.key + }; + const decrypt = crypto.decrypt(config); + + input.pipe(decrypt).pipe(output).on('finish', () => { + + if (!fs.existsSync(`${this.dump}/decrypted.zip`)){ + // will be handling after implementing in client app + reject(); + return; + } + + let readStream = fs.createReadStream(`${this.dump}/decrypted.zip`); + readStream + .on('data', (data) => { + if (!data) { + reject(new Error("error reading zip")); + } + extractZip(); + }) + .on('error', (error) => { + reject(new Error(error.message)); + }); + }); + + let extractZip = () => { + extract(`${this.dump}/decrypted.zip`, {dir: `${this.extractToPath}`}, (err) => { + if (err) { + reject(new Error(err)); + } + fs.unlink(`${this.dump}/decrypted.zip`, () => { + fs.unlink(this.encryptedIndex, () => { + resolve('success'); + }) + }); + }) + } + }); + } + + /** + * Removing all the folders and files inside the data folder + * @param {String} location + * @returns {Promise} + */ + static deleteFolderRecursive(location) { + return new Promise((resolve, reject) => { + if (fs.existsSync(location)) { + fs.readdirSync(location).forEach((file) => { + let curPath = location + "/" + file; + if (fs.lstatSync(curPath).isDirectory()) { + Crypto.deleteFolderRecursive(curPath); + } else { + fs.unlinkSync(curPath); + } + }); + resolve(fs.rmdirSync(location)); + } else { + reject('no file'); + } + }); + } +} + +module.exports = Crypto; \ No newline at end of file diff --git a/js/main.js b/js/main.js index 592e2319..490da048 100644 --- a/js/main.js +++ b/js/main.js @@ -14,9 +14,12 @@ const childProcess = require('child_process'); const path = require('path'); const AppDirectory = require('appdirectory'); const dirs = new AppDirectory('Symphony'); +const Crypto = require('./cryptoLib'); +const crypto = new Crypto(); require('electron-dl')(); + // used to check if a url was opened when the app was already open let isAppAlreadyOpen = false; @@ -68,6 +71,19 @@ if (isMac) { }); } +/** + * This is for demo purpose only + * will be removing this after implementing + * in the client-app + */ +crypto.decryption() + .then(function () { + // will be handling after implementing client app + }) + .catch(function () { + // will be handling after implementing client app + }); + /** * This method will be called when Electron has finished * initialization and is ready to create browser windows. @@ -87,6 +103,25 @@ app.on('activate', function () { } }); +app.on('will-quit', function (e) { + e.preventDefault(); + + /** + * This is for demo purpose only + * will be removing this after implementing + * in client-app + */ + crypto.encryption() + .then(function () { + // will be handling after implementing in client app + app.exit(); + }) + .catch(function () { + // will be handling after implementing client app + app.exit(); + }); +}); + // adds 'symphony' as a protocol // in the system. plist file in macOS // and registry keys in windows diff --git a/package.json b/package.json index 44b0545c..feceb943 100644 --- a/package.json +++ b/package.json @@ -98,15 +98,18 @@ "dependencies": { "@paulcbetts/system-idle-time": "^1.0.4", "appdirectory": "^0.1.0", + "archiver": "^2.0.0", "async.map": "^0.5.2", "async.mapseries": "^0.5.2", "auto-launch": "^5.0.1", "electron-dl": "^1.9.0", "electron-spellchecker": "^1.2.0", "electron-squirrel-startup": "^1.0.0", + "extract-zip": "^1.6.5", "ffi": "^2.2.0", "filesize": "^3.5.10", "keymirror": "0.1.1", + "node-forge": "^0.7.1", "randomstring": "^1.1.5", "ref": "^1.3.4", "winreg": "^1.2.3"