backups: generate keys from password
this also removes storage of password from db part of #579
This commit is contained in:
@@ -32,6 +32,8 @@ exports = module.exports = {
|
||||
|
||||
configureCollectd: configureCollectd,
|
||||
|
||||
generateEncryptionKeysSync: generateEncryptionKeysSync,
|
||||
|
||||
SECRET_PLACEHOLDER: String.fromCharCode(0x25CF).repeat(8),
|
||||
|
||||
// for testing
|
||||
@@ -102,13 +104,19 @@ function api(provider) {
|
||||
}
|
||||
|
||||
function injectPrivateFields(newConfig, currentConfig) {
|
||||
if (newConfig.password === exports.SECRET_PLACEHOLDER) newConfig.password = currentConfig.password;
|
||||
if (newConfig.password === exports.SECRET_PLACEHOLDER) {
|
||||
delete newConfig.password;
|
||||
newConfig.encryption = currentConfig.encryption;
|
||||
}
|
||||
if (newConfig.provider === currentConfig.provider) api(newConfig.provider).injectPrivateFields(newConfig, currentConfig);
|
||||
}
|
||||
|
||||
function removePrivateFields(backupConfig) {
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
if (backupConfig.password) backupConfig.password = exports.SECRET_PLACEHOLDER;
|
||||
if (backupConfig.encryption) {
|
||||
delete backupConfig.encryption;
|
||||
backupConfig.password = exports.SECRET_PLACEHOLDER;
|
||||
}
|
||||
return api(backupConfig.provider).removePrivateFields(backupConfig);
|
||||
}
|
||||
|
||||
@@ -132,6 +140,17 @@ function testConfig(backupConfig, callback) {
|
||||
api(backupConfig.provider).testConfig(backupConfig, callback);
|
||||
}
|
||||
|
||||
function generateEncryptionKeysSync(password) {
|
||||
assert.strictEqual(typeof password, 'string');
|
||||
|
||||
const aesKeys = crypto.scryptSync(password, Buffer.from('CLOUDRONSCRYPTSALT', 'utf8'), 128);
|
||||
return {
|
||||
dataKey: aesKeys.slice(0, 32).toString('hex'),
|
||||
dataHmacKey: aesKeys.slice(32, 64).toString('hex'),
|
||||
filenameKey: aesKeys.slice(64, 96).toString('hex'),
|
||||
filenameHmacKey: aesKeys.slice(96).toString('hex')
|
||||
};
|
||||
}
|
||||
|
||||
function testProviderConfig(backupConfig, callback) {
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
@@ -186,20 +205,21 @@ function getBackupFilePath(backupConfig, backupId, format) {
|
||||
assert.strictEqual(typeof format, 'string');
|
||||
|
||||
if (format === 'tgz') {
|
||||
const fileType = backupConfig.password ? '.tar.gz.enc' : '.tar.gz';
|
||||
const fileType = backupConfig.encryption ? '.tar.gz.enc' : '.tar.gz';
|
||||
return path.join(backupConfig.prefix || backupConfig.backupFolder || '', backupId+fileType);
|
||||
} else {
|
||||
return path.join(backupConfig.prefix || backupConfig.backupFolder || '', backupId);
|
||||
}
|
||||
}
|
||||
|
||||
function encryptFilePath(filePath, key) {
|
||||
function encryptFilePath(filePath, backupConfig) {
|
||||
assert.strictEqual(typeof filePath, 'string');
|
||||
assert(Buffer.isBuffer(key));
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
|
||||
var encryptedParts = filePath.split('/').map(function (part) {
|
||||
let iv = crypto.createHmac('sha256', key).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', key, iv);
|
||||
let hmac = crypto.createHmac('sha256', Buffer.from(backupConfig.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(backupConfig.encryption.filenameKey, 'hex'), iv);
|
||||
let crypt = cipher.update(part);
|
||||
crypt = Buffer.concat([ iv, crypt, cipher.final() ]);
|
||||
|
||||
@@ -211,9 +231,9 @@ function encryptFilePath(filePath, key) {
|
||||
return encryptedParts.join('/');
|
||||
}
|
||||
|
||||
function decryptFilePath(filePath, key) {
|
||||
function decryptFilePath(filePath, backupConfig) {
|
||||
assert.strictEqual(typeof filePath, 'string');
|
||||
assert(Buffer.isBuffer(key));
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
|
||||
let decryptedParts = [];
|
||||
for (let part of filePath.split('/')) {
|
||||
@@ -223,7 +243,7 @@ function decryptFilePath(filePath, key) {
|
||||
try {
|
||||
const buffer = Buffer.from(part, 'base64');
|
||||
const iv = buffer.slice(0, 16);
|
||||
let decrypt = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
let decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(backupConfig.encryption.filenameKey, 'hex'), iv);
|
||||
let text = decrypt.update(buffer.slice(16));
|
||||
text = Buffer.concat([ text, decrypt.final() ]);
|
||||
decryptedParts.push(text.toString('utf8'));
|
||||
@@ -303,9 +323,9 @@ class DecryptStream extends TransformStream {
|
||||
}
|
||||
}
|
||||
|
||||
function createReadStream(sourceFile, key) {
|
||||
function createReadStream(sourceFile, backupConfig) {
|
||||
assert.strictEqual(typeof sourceFile, 'string');
|
||||
assert(key === null || Buffer.isBuffer(key));
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
|
||||
var stream = fs.createReadStream(sourceFile);
|
||||
var ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
|
||||
@@ -315,8 +335,8 @@ function createReadStream(sourceFile, key) {
|
||||
ps.emit('error', new BoxError(BoxError.FS_ERROR, error.message));
|
||||
});
|
||||
|
||||
if (key !== null) {
|
||||
let encryptStream = new EncryptStream(key);
|
||||
if (backupConfig.encryption) {
|
||||
let encryptStream = new EncryptStream(Buffer.from(backupConfig.encryption.dataKey, 'hex'));
|
||||
|
||||
encryptStream.on('error', function (error) {
|
||||
debug('createReadStream: encrypt stream error.', error);
|
||||
@@ -329,9 +349,9 @@ function createReadStream(sourceFile, key) {
|
||||
}
|
||||
}
|
||||
|
||||
function createWriteStream(destFile, key) {
|
||||
function createWriteStream(destFile, backupConfig) {
|
||||
assert.strictEqual(typeof destFile, 'string');
|
||||
assert(key === null || Buffer.isBuffer(key));
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
|
||||
var stream = fs.createWriteStream(destFile);
|
||||
var ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
|
||||
@@ -341,8 +361,8 @@ function createWriteStream(destFile, key) {
|
||||
ps.emit('error', new BoxError(BoxError.FS_ERROR, error.message));
|
||||
});
|
||||
|
||||
if (key !== null) {
|
||||
let decrypt = new DecryptStream(key);
|
||||
if (backupConfig.encryption) {
|
||||
let decrypt = new DecryptStream(Buffer.from(backupConfig.encryption.dataKey, 'hex'));
|
||||
decrypt.on('error', function (error) {
|
||||
debug('createWriteStream: decrypt stream error.', error);
|
||||
ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, error.message));
|
||||
@@ -356,9 +376,9 @@ function createWriteStream(destFile, key) {
|
||||
return ps;
|
||||
}
|
||||
|
||||
function tarPack(dataLayout, key, callback) {
|
||||
function tarPack(dataLayout, backupConfig, callback) {
|
||||
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
||||
assert(key === null || Buffer.isBuffer(key));
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
assert.strictEqual(typeof callback, 'function');
|
||||
|
||||
var pack = tar.pack('/', {
|
||||
@@ -391,8 +411,8 @@ function tarPack(dataLayout, key, callback) {
|
||||
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
||||
});
|
||||
|
||||
if (key !== null) {
|
||||
const encryptStream = new EncryptStream(key);
|
||||
if (backupConfig.encryption) {
|
||||
const encryptStream = new EncryptStream(Buffer.from(backupConfig.encryption.dataKey, 'hex'));
|
||||
encryptStream.on('error', function (error) {
|
||||
debug('tarPack: encrypt stream error.', error);
|
||||
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
||||
@@ -406,14 +426,6 @@ function tarPack(dataLayout, key, callback) {
|
||||
return callback(null, ps);
|
||||
}
|
||||
|
||||
function aesKeyFromPassword(password) {
|
||||
assert(typeof password === 'undefined' || typeof password === 'string');
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
return crypto.scryptSync(password, Buffer.from('CLOUDRONSCRYPTSALT', 'utf8'), 32);
|
||||
}
|
||||
|
||||
function sync(backupConfig, backupId, dataLayout, progressCallback, callback) {
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
assert.strictEqual(typeof backupId, 'string');
|
||||
@@ -423,12 +435,11 @@ function sync(backupConfig, backupId, dataLayout, progressCallback, callback) {
|
||||
|
||||
// the number here has to take into account the s3.upload partSize (which is 10MB). So 20=200MB
|
||||
const concurrency = backupConfig.syncConcurrency || (backupConfig.provider === 's3' ? 20 : 10);
|
||||
const aesKey = aesKeyFromPassword(backupConfig.password);
|
||||
|
||||
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.password ? encryptFilePath(task.path, aesKey) : task.path;
|
||||
const destPath = task.path && backupConfig.encryption ? encryptFilePath(task.path, backupConfig) : task.path;
|
||||
const backupFilePath = path.join(getBackupFilePath(backupConfig, backupId, backupConfig.format), destPath);
|
||||
|
||||
if (task.operation === 'removedir') {
|
||||
@@ -449,7 +460,7 @@ function sync(backupConfig, backupId, 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}`);
|
||||
var stream = createReadStream(dataLayout.toLocalPath('./' + task.path), aesKey);
|
||||
var stream = createReadStream(dataLayout.toLocalPath('./' + task.path), backupConfig);
|
||||
stream.on('error', function (error) {
|
||||
debug(`read stream error for ${task.path}: ${error.message}`);
|
||||
retryCallback();
|
||||
@@ -547,11 +558,10 @@ function upload(backupId, format, dataLayoutString, progressCallback, callback)
|
||||
if (error) return callback(error);
|
||||
|
||||
if (format === 'tgz') {
|
||||
const aesKey = aesKeyFromPassword(backupConfig.password);
|
||||
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
||||
retryCallback = once(retryCallback); // protect again upload() erroring much later after tar stream error
|
||||
|
||||
tarPack(dataLayout, aesKey, function (error, tarStream) {
|
||||
tarPack(dataLayout, backupConfig, function (error, tarStream) {
|
||||
if (error) return retryCallback(error);
|
||||
|
||||
tarStream.on('progress', function (progress) {
|
||||
@@ -574,10 +584,10 @@ function upload(backupId, format, dataLayoutString, progressCallback, callback)
|
||||
});
|
||||
}
|
||||
|
||||
function tarExtract(inStream, dataLayout, key, callback) {
|
||||
function tarExtract(inStream, dataLayout, backupConfig, callback) {
|
||||
assert.strictEqual(typeof inStream, 'object');
|
||||
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
||||
assert(key === null || Buffer.isBuffer(key));
|
||||
assert.strictEqual(typeof backupConfig, 'object');
|
||||
assert.strictEqual(typeof callback, 'function');
|
||||
|
||||
var gunzip = zlib.createGunzip({});
|
||||
@@ -612,8 +622,8 @@ function tarExtract(inStream, dataLayout, key, callback) {
|
||||
ps.emit('done');
|
||||
});
|
||||
|
||||
if (key !== null) {
|
||||
let decrypt = new DecryptStream(key);
|
||||
if (backupConfig.encryption) {
|
||||
let decrypt = new DecryptStream(Buffer.from(backupConfig.encryption.dataKey, 'hex'));
|
||||
decrypt.on('error', function (error) {
|
||||
debug('tarExtract: decrypt stream error.', error);
|
||||
emitError(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to decrypt: ${error.message}`));
|
||||
@@ -662,12 +672,10 @@ function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback,
|
||||
|
||||
debug(`downloadDir: ${backupFilePath} to ${dataLayout.toString()}`);
|
||||
|
||||
const aesKey = aesKeyFromPassword(backupConfig.password);
|
||||
|
||||
function downloadFile(entry, done) {
|
||||
let relativePath = path.relative(backupFilePath, entry.fullPath);
|
||||
if (backupConfig.password) {
|
||||
relativePath = decryptFilePath(relativePath, aesKey);
|
||||
if (backupConfig.encryption) {
|
||||
relativePath = decryptFilePath(relativePath, backupConfig);
|
||||
if (!relativePath) return done(new BoxError(BoxError.CRYPTO_ERROR, 'Unable to decrypt file'));
|
||||
}
|
||||
const destFilePath = dataLayout.toLocalPath('./' + relativePath);
|
||||
@@ -676,7 +684,7 @@ function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback,
|
||||
if (error) return done(new BoxError(BoxError.FS_ERROR, error.message));
|
||||
|
||||
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
||||
let destStream = createWriteStream(destFilePath, aesKey);
|
||||
let destStream = createWriteStream(destFilePath, backupConfig);
|
||||
|
||||
destStream.on('progress', function (progress) {
|
||||
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
|
||||
@@ -727,12 +735,11 @@ function download(backupConfig, backupId, format, dataLayout, progressCallback,
|
||||
const backupFilePath = getBackupFilePath(backupConfig, backupId, format);
|
||||
|
||||
if (format === 'tgz') {
|
||||
const aesKey = aesKeyFromPassword(backupConfig.password);
|
||||
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
||||
api(backupConfig.provider).download(backupConfig, backupFilePath, function (error, sourceStream) {
|
||||
if (error) return retryCallback(error);
|
||||
|
||||
tarExtract(sourceStream, dataLayout, aesKey, function (error, ps) {
|
||||
tarExtract(sourceStream, dataLayout, backupConfig, function (error, ps) {
|
||||
if (error) return retryCallback(error);
|
||||
|
||||
ps.on('progress', function (progress) {
|
||||
|
||||
Reference in New Issue
Block a user