diff --git a/migrations/20200512172301-rename-backup-key-to-password.js b/migrations/20200512172301-rename-backup-key-to-password.js deleted file mode 100644 index 676df7a88..000000000 --- a/migrations/20200512172301-rename-backup-key-to-password.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -exports.up = function(db, callback) { - db.all('SELECT value FROM settings WHERE name="backup_config"', function (error, results) { - if (error || results.length === 0) return callback(error); - - var backupConfig = JSON.parse(results[0].value); - if (backupConfig.key) { - backupConfig.password = backupConfig.key; - } else { - delete backupConfig.key; - } - - db.runSql('UPDATE settings SET value=? WHERE name="backup_config"', [ JSON.stringify(backupConfig) ], callback); - }); -}; - -exports.down = function(db, callback) { - callback(); -}; diff --git a/migrations/20200512172301-settings-backup-encryption.js b/migrations/20200512172301-settings-backup-encryption.js new file mode 100644 index 000000000..8efaf341f --- /dev/null +++ b/migrations/20200512172301-settings-backup-encryption.js @@ -0,0 +1,30 @@ +'use strict'; + +const crypto = require('crypto'); + +exports.up = function(db, callback) { + db.all('SELECT value FROM settings WHERE name="backup_config"', function (error, results) { + if (error || results.length === 0) return callback(error); + + var backupConfig = JSON.parse(results[0].value); + if (backupConfig.key) { + const aesKeys = crypto.scryptSync(backupConfig.key, Buffer.from('CLOUDRONSCRYPTSALT', 'utf8'), 128); + backupConfig.encryption = { + 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') + }; + } else { + backupConfig.encryption = null; + } + + delete backupConfig.key; + + db.runSql('UPDATE settings SET value=? WHERE name="backup_config"', [ JSON.stringify(backupConfig) ], callback); + }); +}; + +exports.down = function(db, callback) { + callback(); +}; diff --git a/src/backups.js b/src/backups.js index e4caad10c..6c526526e 100644 --- a/src/backups.js +++ b/src/backups.js @@ -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) { diff --git a/src/settings.js b/src/settings.js index f06f8ddfd..839875766 100644 --- a/src/settings.js +++ b/src/settings.js @@ -401,6 +401,15 @@ function setBackupConfig(backupConfig, callback) { backups.testConfig(backupConfig, function (error) { if (error) return callback(error); + if ('password' in backupConfig) { // user changed password + if (backupConfig.password) { + backupConfig.encryption = backups.generateEncryptionKeysSync(backupConfig.password); + delete backupConfig.password; + } else { + backupConfig.encryption = null; + } + } + backups.cleanupCacheFilesSync(); settingsdb.set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig), function (error) {