From c67d9fd0823143a2948336d4c980c24e19e6e8a0 Mon Sep 17 00:00:00 2001 From: Girish Ramakrishnan Date: Thu, 28 Apr 2022 18:10:58 -0700 Subject: [PATCH] move crypto code to hush.js --- src/backuptask.js | 220 ++------------------------------------------- src/hush.js | 225 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 213 deletions(-) create mode 100644 src/hush.js diff --git a/src/backuptask.js b/src/backuptask.js index 88100494a..456eb61ef 100644 --- a/src/backuptask.js +++ b/src/backuptask.js @@ -23,11 +23,11 @@ const apps = require('./apps.js'), backups = require('./backups.js'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), - crypto = require('crypto'), DataLayout = require('./datalayout.js'), database = require('./database.js'), debug = require('debug')('box:backuptask'), fs = require('fs'), + hush = require('./hush.js'), once = require('./once.js'), path = require('path'), paths = require('./paths.js'), @@ -39,7 +39,6 @@ const apps = require('./apps.js'), storage = require('./storage.js'), syncer = require('./syncer.js'), tar = require('tar-fs'), - TransformStream = require('stream').Transform, zlib = require('zlib'), util = require('util'); @@ -61,211 +60,6 @@ function canBackupApp(app) { app.installationState === apps.ISTATE_PENDING_UPDATE; // called from apptask } -function encryptFilePath(filePath, encryption) { - assert.strictEqual(typeof filePath, 'string'); - assert.strictEqual(typeof encryption, 'object'); - - const encryptedParts = filePath.split('/').map(function (part) { - let hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex')); - const iv = hmac.update(part).digest().slice(0, 16); // iv has to be deterministic, for our sync (copy) logic to work - const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv); - let crypt = cipher.update(part); - crypt = Buffer.concat([ iv, crypt, cipher.final() ]); - - return crypt.toString('base64') // ensures path is valid - .replace(/\//g, '-') // replace '/' of base64 since it conflicts with path separator - .replace(/=/g,''); // strip trailing = padding. this is only needed if we concat base64 strings, which we don't - }); - - return encryptedParts.join('/'); -} - -function decryptFilePath(filePath, encryption) { - assert.strictEqual(typeof filePath, 'string'); - assert.strictEqual(typeof encryption, 'object'); - - const decryptedParts = []; - for (let part of filePath.split('/')) { - part = part + Array(part.length % 4).join('='); // add back = padding - part = part.replace(/-/g, '/'); // replace with '/' - - try { - const buffer = Buffer.from(part, 'base64'); - const iv = buffer.slice(0, 16); - let decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv); - const plainText = decrypt.update(buffer.slice(16)); - const plainTextString = Buffer.concat([ plainText, decrypt.final() ]).toString('utf8'); - const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex')); - if (!hmac.update(plainTextString).digest().slice(0, 16).equals(iv)) return { error: new BoxError(BoxError.CRYPTO_ERROR, `mac error decrypting part ${part} of path ${filePath}`) }; - - decryptedParts.push(plainTextString); - } catch (error) { - debug(`Error decrypting part ${part} of path ${filePath}:`, error); - return { error: new BoxError(BoxError.CRYPTO_ERROR, `Error decrypting part ${part} of path ${filePath}: ${error.message}`) }; - } - } - - return { result: decryptedParts.join('/') }; -} - -class EncryptStream extends TransformStream { - constructor(encryption) { - super(); - this._headerPushed = false; - this._iv = crypto.randomBytes(16); - this._cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.dataKey, 'hex'), this._iv); - this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex')); - } - - pushHeaderIfNeeded() { - if (!this._headerPushed) { - const magic = Buffer.from('CBV2'); - this.push(magic); - this._hmac.update(magic); - this.push(this._iv); - this._hmac.update(this._iv); - this._headerPushed = true; - } - } - - _transform(chunk, ignoredEncoding, callback) { - this.pushHeaderIfNeeded(); - - try { - const crypt = this._cipher.update(chunk); - this._hmac.update(crypt); - callback(null, crypt); - } catch (error) { - callback(error); - } - } - - _flush(callback) { - try { - this.pushHeaderIfNeeded(); // for 0-length files - const crypt = this._cipher.final(); - this.push(crypt); - this._hmac.update(crypt); - callback(null, this._hmac.digest()); // +32 bytes - } catch (error) { - callback(error); - } - } -} - -class DecryptStream extends TransformStream { - constructor(encryption) { - super(); - this._key = Buffer.from(encryption.dataKey, 'hex'); - this._header = Buffer.alloc(0); - this._decipher = null; - this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex')); - this._buffer = Buffer.alloc(0); - } - - _transform(chunk, ignoredEncoding, callback) { - const needed = 20 - this._header.length; // 4 for magic, 16 for iv - - if (this._header.length !== 20) { // not gotten header yet - this._header = Buffer.concat([this._header, chunk.slice(0, needed)]); - if (this._header.length !== 20) return callback(); - - if (!this._header.slice(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid magic in header')); - - const iv = this._header.slice(4); - this._decipher = crypto.createDecipheriv('aes-256-cbc', this._key, iv); - this._hmac.update(this._header); - } - - this._buffer = Buffer.concat([ this._buffer, chunk.slice(needed) ]); - if (this._buffer.length < 32) return callback(); // hmac trailer length is 32 - - try { - const cipherText = this._buffer.slice(0, -32); - this._hmac.update(cipherText); - const plainText = this._decipher.update(cipherText); - this._buffer = this._buffer.slice(-32); - callback(null, plainText); - } catch (error) { - callback(error); - } - } - - _flush (callback) { - if (this._buffer.length !== 32) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid password or tampered file (not enough data)')); - - try { - if (!this._hmac.digest().equals(this._buffer)) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid password or tampered file (mac mismatch)')); - - const plainText = this._decipher.final(); - callback(null, plainText); - } catch (error) { - callback(error); - } - } -} - -function createReadStream(sourceFile, encryption) { - assert.strictEqual(typeof sourceFile, 'string'); - assert.strictEqual(typeof encryption, 'object'); - - const stream = fs.createReadStream(sourceFile); - const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds - - stream.on('error', function (error) { - debug(`createReadStream: read stream error at ${sourceFile}`, error); - ps.emit('error', new BoxError(BoxError.FS_ERROR, `Error reading ${sourceFile}: ${error.message} ${error.code}`)); - }); - - stream.on('open', () => ps.emit('open')); - - if (encryption) { - let encryptStream = new EncryptStream(encryption); - - encryptStream.on('error', function (error) { - debug(`createReadStream: encrypt stream error ${sourceFile}`, error); - ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Encryption error at ${sourceFile}: ${error.message}`)); - }); - - return stream.pipe(encryptStream).pipe(ps); - } else { - return stream.pipe(ps); - } -} - -function createWriteStream(destFile, encryption) { - assert.strictEqual(typeof destFile, 'string'); - assert.strictEqual(typeof encryption, 'object'); - - const stream = fs.createWriteStream(destFile); - const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds - - stream.on('error', function (error) { - debug(`createWriteStream: write stream error ${destFile}`, error); - ps.emit('error', new BoxError(BoxError.FS_ERROR, `Write error ${destFile}: ${error.message}`)); - }); - - stream.on('finish', function () { - debug('createWriteStream: done.'); - // we use a separate event because ps is a through2 stream which emits 'finish' event indicating end of inStream and not write - ps.emit('done'); - }); - - if (encryption) { - let decrypt = new DecryptStream(encryption); - decrypt.on('error', function (error) { - debug(`createWriteStream: decrypt stream error ${destFile}`, error); - ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Decryption error at ${destFile}: ${error.message}`)); - }); - - ps.pipe(decrypt).pipe(stream); - } else { - ps.pipe(stream); - } - - return ps; -} - function tarPack(dataLayout, encryption, callback) { assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout'); assert.strictEqual(typeof encryption, 'object'); @@ -302,7 +96,7 @@ function tarPack(dataLayout, encryption, callback) { }); if (encryption) { - const encryptStream = new EncryptStream(encryption); + const encryptStream = new hush.EncryptStream(encryption); encryptStream.on('error', function (error) { debug('tarPack: encrypt stream error.', error); ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message)); @@ -331,7 +125,7 @@ function sync(backupConfig, remotePath, dataLayout, progressCallback, callback) syncer.sync(dataLayout, function processTask(task, iteratorCallback) { debug('sync: processing task: %j', task); // the empty task.path is special to signify the directory - const destPath = task.path && backupConfig.encryption ? encryptFilePath(task.path, backupConfig.encryption) : task.path; + const destPath = task.path && backupConfig.encryption ? hush.encryptFilePath(task.path, backupConfig.encryption) : task.path; const backupFilePath = path.join(storage.getBackupFilePath(backupConfig, remotePath, backupConfig.format), destPath); if (task.operation === 'removedir') { @@ -350,7 +144,7 @@ function sync(backupConfig, remotePath, dataLayout, progressCallback, callback) if (task.operation === 'add') { progressCallback({ message: `Adding ${task.path}` + (retryCount > 1 ? ` (Try ${retryCount})` : '') }); debug(`Adding ${task.path} position ${task.position} try ${retryCount}`); - const stream = createReadStream(dataLayout.toLocalPath('./' + task.path), backupConfig.encryption); + const stream = hush.createReadStream(dataLayout.toLocalPath('./' + task.path), backupConfig.encryption); stream.on('error', (error) => retryCallback(error.message.includes('ENOENT') ? null : error)); // ignore error if file disappears stream.on('progress', function (progress) { const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024); @@ -494,7 +288,7 @@ function tarExtract(inStream, dataLayout, encryption, callback) { }); if (encryption) { - let decrypt = new DecryptStream(encryption); + let decrypt = new hush.DecryptStream(encryption); decrypt.on('error', function (error) { debug('tarExtract: decrypt stream error.', error); emitError(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to decrypt: ${error.message}`)); @@ -550,7 +344,7 @@ function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback, function downloadFile(entry, done) { let relativePath = path.relative(backupFilePath, entry.fullPath); if (backupConfig.encryption) { - const { error, result } = decryptFilePath(relativePath, backupConfig.encryption); + const { error, result } = hush.decryptFilePath(relativePath, backupConfig.encryption); if (error) return done(new BoxError(BoxError.CRYPTO_ERROR, 'Unable to decrypt file')); relativePath = result; } @@ -566,7 +360,7 @@ function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback, return retryCallback(error); } - let destStream = createWriteStream(destFilePath, backupConfig.encryption); + let destStream = hush.createWriteStream(destFilePath, backupConfig.encryption); // protect against multiple errors. must destroy the write stream so that a previous retry does not write let closeAndRetry = once((error) => { diff --git a/src/hush.js b/src/hush.js new file mode 100644 index 000000000..d4d16d476 --- /dev/null +++ b/src/hush.js @@ -0,0 +1,225 @@ +'use strict'; + +exports = module.exports = { + EncryptStream, + DecryptStream, + + encryptFilePath, + decryptFilePath, + + createReadStream, + createWriteStream +}; + +const assert = require('assert'), + BoxError = require('./boxerror.js'), + crypto = require('crypto'), + debug = require('debug')('box:hush'), + fs = require('fs'), + progressStream = require('progress-stream'), + TransformStream = require('stream').Transform; + +class EncryptStream extends TransformStream { + constructor(encryption) { + super(); + this._headerPushed = false; + this._iv = crypto.randomBytes(16); + this._cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.dataKey, 'hex'), this._iv); + this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex')); + } + + pushHeaderIfNeeded() { + if (!this._headerPushed) { + const magic = Buffer.from('CBV2'); + this.push(magic); + this._hmac.update(magic); + this.push(this._iv); + this._hmac.update(this._iv); + this._headerPushed = true; + } + } + + _transform(chunk, ignoredEncoding, callback) { + this.pushHeaderIfNeeded(); + + try { + const crypt = this._cipher.update(chunk); + this._hmac.update(crypt); + callback(null, crypt); + } catch (error) { + callback(error); + } + } + + _flush(callback) { + try { + this.pushHeaderIfNeeded(); // for 0-length files + const crypt = this._cipher.final(); + this.push(crypt); + this._hmac.update(crypt); + callback(null, this._hmac.digest()); // +32 bytes + } catch (error) { + callback(error); + } + } +} + +class DecryptStream extends TransformStream { + constructor(encryption) { + super(); + this._key = Buffer.from(encryption.dataKey, 'hex'); + this._header = Buffer.alloc(0); + this._decipher = null; + this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex')); + this._buffer = Buffer.alloc(0); + } + + _transform(chunk, ignoredEncoding, callback) { + const needed = 20 - this._header.length; // 4 for magic, 16 for iv + + if (this._header.length !== 20) { // not gotten header yet + this._header = Buffer.concat([this._header, chunk.slice(0, needed)]); + if (this._header.length !== 20) return callback(); + + if (!this._header.slice(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid magic in header')); + + const iv = this._header.slice(4); + this._decipher = crypto.createDecipheriv('aes-256-cbc', this._key, iv); + this._hmac.update(this._header); + } + + this._buffer = Buffer.concat([ this._buffer, chunk.slice(needed) ]); + if (this._buffer.length < 32) return callback(); // hmac trailer length is 32 + + try { + const cipherText = this._buffer.slice(0, -32); + this._hmac.update(cipherText); + const plainText = this._decipher.update(cipherText); + this._buffer = this._buffer.slice(-32); + callback(null, plainText); + } catch (error) { + callback(error); + } + } + + _flush (callback) { + if (this._buffer.length !== 32) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid password or tampered file (not enough data)')); + + try { + if (!this._hmac.digest().equals(this._buffer)) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid password or tampered file (mac mismatch)')); + + const plainText = this._decipher.final(); + callback(null, plainText); + } catch (error) { + callback(error); + } + } +} + +function encryptFilePath(filePath, encryption) { + assert.strictEqual(typeof filePath, 'string'); + assert.strictEqual(typeof encryption, 'object'); + + const encryptedParts = filePath.split('/').map(function (part) { + let hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex')); + const iv = hmac.update(part).digest().slice(0, 16); // iv has to be deterministic, for our sync (copy) logic to work + const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv); + let crypt = cipher.update(part); + crypt = Buffer.concat([ iv, crypt, cipher.final() ]); + + return crypt.toString('base64') // ensures path is valid + .replace(/\//g, '-') // replace '/' of base64 since it conflicts with path separator + .replace(/=/g,''); // strip trailing = padding. this is only needed if we concat base64 strings, which we don't + }); + + return encryptedParts.join('/'); +} + +function decryptFilePath(filePath, encryption) { + assert.strictEqual(typeof filePath, 'string'); + assert.strictEqual(typeof encryption, 'object'); + + const decryptedParts = []; + for (let part of filePath.split('/')) { + part = part + Array(part.length % 4).join('='); // add back = padding + part = part.replace(/-/g, '/'); // replace with '/' + + try { + const buffer = Buffer.from(part, 'base64'); + const iv = buffer.slice(0, 16); + let decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv); + const plainText = decrypt.update(buffer.slice(16)); + const plainTextString = Buffer.concat([ plainText, decrypt.final() ]).toString('utf8'); + const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex')); + if (!hmac.update(plainTextString).digest().slice(0, 16).equals(iv)) return { error: new BoxError(BoxError.CRYPTO_ERROR, `mac error decrypting part ${part} of path ${filePath}`) }; + + decryptedParts.push(plainTextString); + } catch (error) { + debug(`Error decrypting part ${part} of path ${filePath}:`, error); + return { error: new BoxError(BoxError.CRYPTO_ERROR, `Error decrypting part ${part} of path ${filePath}: ${error.message}`) }; + } + } + + return { result: decryptedParts.join('/') }; +} + +function createReadStream(sourceFile, encryption) { + assert.strictEqual(typeof sourceFile, 'string'); + assert.strictEqual(typeof encryption, 'object'); + + const stream = fs.createReadStream(sourceFile); + const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds + + stream.on('error', function (error) { + debug(`createReadStream: read stream error at ${sourceFile}`, error); + ps.emit('error', new BoxError(BoxError.FS_ERROR, `Error reading ${sourceFile}: ${error.message} ${error.code}`)); + }); + + stream.on('open', () => ps.emit('open')); + + if (encryption) { + let encryptStream = new EncryptStream(encryption); + + encryptStream.on('error', function (error) { + debug(`createReadStream: encrypt stream error ${sourceFile}`, error); + ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Encryption error at ${sourceFile}: ${error.message}`)); + }); + + return stream.pipe(encryptStream).pipe(ps); + } else { + return stream.pipe(ps); + } +} + +function createWriteStream(destFile, encryption) { + assert.strictEqual(typeof destFile, 'string'); + assert.strictEqual(typeof encryption, 'object'); + + const stream = fs.createWriteStream(destFile); + const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds + + stream.on('error', function (error) { + debug(`createWriteStream: write stream error ${destFile}`, error); + ps.emit('error', new BoxError(BoxError.FS_ERROR, `Write error ${destFile}: ${error.message}`)); + }); + + stream.on('finish', function () { + debug('createWriteStream: done.'); + // we use a separate event because ps is a through2 stream which emits 'finish' event indicating end of inStream and not write + ps.emit('done'); + }); + + if (encryption) { + let decrypt = new DecryptStream(encryption); + decrypt.on('error', function (error) { + debug(`createWriteStream: decrypt stream error ${destFile}`, error); + ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Decryption error at ${destFile}: ${error.message}`)); + }); + + ps.pipe(decrypt).pipe(stream); + } else { + ps.pipe(stream); + } + + return ps; +}