move crypto code to hush.js
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user