'use strict'; const assert = require('assert'), BoxError = require('./boxerror.js'), crypto = require('crypto'), debug = require('debug')('box:hush'), 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) { assert(Buffer.isBuffer(chunk)); 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.subarray(0, needed)]); if (this._header.length !== 20) return callback(); if (!this._header.subarray(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid magic in header')); const iv = this._header.subarray(4); this._decipher = crypto.createDecipheriv('aes-256-cbc', this._key, iv); this._hmac.update(this._header); } this._buffer = Buffer.concat([ this._buffer, chunk.subarray(needed) ]); if (this._buffer.length < 32) return callback(); // hmac trailer length is 32 try { const cipherText = this._buffer.subarray(0, -32); this._hmac.update(cipherText); const plainText = this._decipher.update(cipherText); this._buffer = this._buffer.subarray(-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) { const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex')); const iv = hmac.update(part).digest().subarray(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.subarray(0, 16); const decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv); const plainText = decrypt.update(buffer.subarray(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().subarray(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('/') }; } exports = module.exports = { EncryptStream, DecryptStream, encryptFilePath, decryptFilePath, };