'use strict'; const assert = require('assert'), BoxError = require('./boxerror.js'), crypto = require('crypto'), debug = require('debug')('box:hush'), fs = require('fs'), ProgressStream = require('./progress-stream.js'), 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(new BoxError(BoxError.CRYPTO_ERROR, `Encryption error when updating: ${error.message}`)); } } _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(new BoxError(BoxError.CRYPTO_ERROR, `Encryption error when flushing: ${error.message}`)); } } } 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(new BoxError(BoxError.CRYPTO_ERROR, `Decryption error: ${error.message}`)); } } _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(new BoxError(BoxError.CRYPTO_ERROR, `Invalid password or tampered file: ${error.message}`)); } } } 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}: %o`, 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 = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds stream.on('error', function (error) { debug(`createReadStream: read stream error at ${sourceFile}. %o`, 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}. %o`, 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); } } exports = module.exports = { EncryptStream, DecryptStream, encryptFilePath, decryptFilePath, createReadStream, };