From d29abc27242d3abbb5edaf37ef6275f3a05b7be0 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Fri, 23 Sep 2016 10:38:30 +0100 Subject: [PATCH] Encrypt credentials by default --- red/runtime/locales/en-US/runtime.json | 1 + red/runtime/nodes/credentials.js | 151 +++++++++- red/runtime/nodes/index.js | 2 +- red/runtime/settings.js | 13 + settings.js | 8 + test/nodes/helper.js | 2 +- test/red/runtime/nodes/credentials_spec.js | 333 +++++++++++++++++++-- test/red/runtime/nodes/index_spec.js | 6 +- 8 files changed, 477 insertions(+), 39 deletions(-) diff --git a/red/runtime/locales/en-US/runtime.json b/red/runtime/locales/en-US/runtime.json index a4f9840a3..edce362c7 100644 --- a/red/runtime/locales/en-US/runtime.json +++ b/red/runtime/locales/en-US/runtime.json @@ -72,6 +72,7 @@ "nodes": { "credentials": { "error":"Error loading credentials: __message__", + "error-saving":"Error saving credentials: __message__", "not-registered": "Credential type '__type__' is not registered" }, "flows": { diff --git a/red/runtime/nodes/credentials.js b/red/runtime/nodes/credentials.js index 9f46a8a29..9df6e4c3c 100644 --- a/red/runtime/nodes/credentials.js +++ b/red/runtime/nodes/credentials.js @@ -15,32 +15,142 @@ **/ var when = require("when"); +var crypto = require('crypto'); +var settings; +var log; -var log = require("../log"); - +var encryptedCredentials = null; var credentialCache = {}; var credentialsDef = {}; var dirty = false; +var removeDefaultKey = false; +var encryptionEnabled = null; +var encryptionAlgorithm = "aes-256-ctr"; +var encryptionKey; + +function decryptCredentials(key,credentials) { + var creds = credentials["$"]; + var initVector = new Buffer(creds.substring(0, 32),'hex'); + creds = creds.substring(32); + var decipher = crypto.createDecipheriv(encryptionAlgorithm, key, initVector); + var decrypted = decipher.update(creds, 'base64', 'utf8') + decipher.final('utf8'); + return JSON.parse(decrypted); +} + var api = module.exports = { - init: function() { + init: function(runtime) { + log = runtime.log; + settings = runtime.settings; dirty = false; credentialCache = {}; credentialsDef = {}; + encryptionEnabled = null; }, /** * Sets the credentials from storage. */ load: function (credentials) { - credentialCache = credentials; dirty = false; - return when.resolve(); - // return storage.getCredentials().then(function (creds) { - // credentialCache = creds; - // }).otherwise(function (err) { - // log.warn(log._("nodes.credentials.error",{message: err})); - // }); + /* + - if encryptionEnabled === null, check the current configuration + */ + var credentialsEncrypted = credentials.hasOwnProperty("$") && Object.keys(credentials).length === 1; + var setupEncryptionPromise = when.resolve(); + if (encryptionEnabled === null) { + var defaultKey; + try { + defaultKey = settings.get('_credentialSecret'); + } catch(err) { + } + if (defaultKey) { + defaultKey = crypto.createHash('sha256').update(defaultKey).digest(); + } + var userKey; + try { + userKey = settings.get('credentialSecret'); + } catch(err) { + userKey = false; + } + if (userKey === false) { + log.debug("red/runtime/nodes/credentials.load : user disabled encryption"); + // User has disabled encryption + encryptionEnabled = false; + // Check if we have a generated _credSecret to decrypt with and remove + if (defaultKey) { + log.debug("red/runtime/nodes/credentials.load : default key present. Will migrate"); + if (credentialsEncrypted) { + try { + credentials = decryptCredentials(defaultKey,credentials) + } catch(err) { + credentials = {}; + log.warn(log._("nodes.credentials.error",{message:err.toString()})) + } + } + dirty = true; + removeDefaultKey = true; + } + } else if (typeof userKey === 'string') { + log.debug("red/runtime/nodes/credentials.load : user provided key"); + // User has provided own encryption key, get the 32-byte hash of it + encryptionKey = crypto.createHash('sha256').update(userKey).digest(); + encryptionEnabled = true; + + if (defaultKey) { + log.debug("red/runtime/nodes/credentials.load : default key present. Will migrate"); + // User has provided their own key, but we already have a default key + // Decrypt using default key + if (credentialsEncrypted) { + try { + credentials = decryptCredentials(defaultKey,credentials) + } catch(err) { + credentials = {}; + log.warn(log._("nodes.credentials.error",{message:err.toString()})) + } + } + dirty = true; + removeDefaultKey = true; + } + } else { + log.debug("red/runtime/nodes/credentials.load : no user key present"); + // User has not provide their own key + encryptionKey = defaultKey; + encryptionEnabled = true; + if (encryptionKey === undefined) { + log.debug("red/runtime/nodes/credentials.load : no default key present - generating one"); + // No user-provided key, no generated key + // Generate a new key + defaultKey = crypto.randomBytes(32).toString('hex'); + try { + setupEncryptionPromise = settings.set('_credentialSecret',defaultKey); + encryptionKey = crypto.createHash('sha256').update(defaultKey).digest(); + } catch(err) { + log.debug("red/runtime/nodes/credentials.load : settings unavailable - disabling encryption"); + // Settings unavailable + encryptionEnabled = false; + encryptionKey = null; + } + dirty = true; + } else { + log.debug("red/runtime/nodes/credentials.load : using default key"); + } + } + } + return setupEncryptionPromise.then(function() { + if (credentials.hasOwnProperty("$")) { + // These are encrypted credentials + try { + credentialCache = decryptCredentials(encryptionKey,credentials) + } catch(err) { + credentialCache = {}; + dirty = true; + log.warn(log._("nodes.credentials.error",{message:err.toString()})) + } + } else { + credentialCache = credentials; + } + }); }, /** @@ -172,7 +282,26 @@ var api = module.exports = { }, export: function() { + var result = credentialCache; + if (dirty && encryptionEnabled) { + try { + log.debug("red/runtime/nodes/credentials.export : encrypting"); + var initVector = crypto.randomBytes(16); + var cipher = crypto.createCipheriv(encryptionAlgorithm, encryptionKey, initVector); + result = {"$":initVector.toString('hex') + cipher.update(JSON.stringify(credentialCache), 'utf8', 'base64') + cipher.final('base64')}; + } catch(err) { + log.warn(log._("nodes.credentials.error-saving",{message:err.toString()})) + } + } dirty = false; - return when.resolve(credentialCache); + if (removeDefaultKey) { + log.debug("red/runtime/nodes/credentials.export : removing unused default key"); + return settings.delete('_credentialSecret').then(function() { + removeDefaultKey = false; + return result; + }) + } else { + return when.resolve(result); + } } } diff --git a/red/runtime/nodes/index.js b/red/runtime/nodes/index.js index 32548f60d..55c9c8e79 100644 --- a/red/runtime/nodes/index.js +++ b/red/runtime/nodes/index.js @@ -77,7 +77,7 @@ function createNode(node,def) { function init(runtime) { settings = runtime.settings; - credentials.init(); + credentials.init(runtime); flows.init(runtime); registry.init(runtime); context.init(runtime.settings); diff --git a/red/runtime/settings.js b/red/runtime/settings.js index cde05280e..bd62bfce6 100644 --- a/red/runtime/settings.js +++ b/red/runtime/settings.js @@ -71,6 +71,19 @@ var persistentSettings = { return storage.saveSettings(globalSettings); } }, + delete: function(prop) { + if (userSettings.hasOwnProperty(prop)) { + throw new Error(log._("settings.property-read-only", {prop:prop})); + } + if (globalSettings === null) { + throw new Error(log._("settings.not-available")); + } + if (globalSettings.hasOwnProperty(prop)) { + delete globalSettings[prop]; + return storage.saveSettings(globalSettings); + } + return when.resolve(); + }, available: function() { return (globalSettings !== null); diff --git a/settings.js b/settings.js index 5ec7fcef0..10a01913a 100644 --- a/settings.js +++ b/settings.js @@ -54,6 +54,14 @@ module.exports = { // property to true: //flowFilePretty: true, + // By default, credentials are encrypted in storage using a generated key. To + // specify your own secret, set the following property. + // If you want to disable encryption of credentials, set this property to false. + // Note: once you set this property, do not change it - doing so will prevent + // node-red from being able to decrypt your existing credentials and they will be + // lost. + //credentialSecret: "a-secret-key", + // By default, all user data is stored in the Node-RED install directory. To // use a different location, the following property can be used //userDir: '/home/nol/.node-red/', diff --git a/test/nodes/helper.js b/test/nodes/helper.js index 8cdcdb1e5..74b4348e2 100644 --- a/test/nodes/helper.js +++ b/test/nodes/helper.js @@ -92,7 +92,7 @@ module.exports = { return messageId; }; - redNodes.init({settings:settings, storage:storage}); + redNodes.init({settings:settings, storage:storage,log:log}); RED.nodes.registerType("helper", helperNode); if (Array.isArray(testNode)) { for (i = 0; i < testNode.length; i++) { diff --git a/test/red/runtime/nodes/credentials_spec.js b/test/red/runtime/nodes/credentials_spec.js index 481e4d669..f8372b499 100644 --- a/test/red/runtime/nodes/credentials_spec.js +++ b/test/red/runtime/nodes/credentials_spec.js @@ -26,12 +26,21 @@ var log = require("../../../../red/runtime/log"); describe('red/runtime/nodes/credentials', function() { + var encryptionDisabledSettings = { + get: function(key) { + return false; + } + } + afterEach(function() { index.clearRegistry(); }); it('loads provided credentials',function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); credentials.load({"a":{"b":1,"c":2}}).then(function() { @@ -42,37 +51,46 @@ describe('red/runtime/nodes/credentials', function() { }); }); it('adds a new credential',function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); credentials.load({"a":{"b":1,"c":2}}).then(function() { - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); should.not.exist(credentials.get("b")); credentials.add("b",{"foo":"bar"}).then(function() { credentials.get("b").should.have.property("foo","bar"); - credentials.dirty().should.be.true; + credentials.dirty().should.be.true(); done(); }); }); }); it('deletes an existing credential',function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); credentials.load({"a":{"b":1,"c":2}}).then(function() { - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); credentials.delete("a"); should.not.exist(credentials.get("a")); - credentials.dirty().should.be.true; + credentials.dirty().should.be.true(); done(); }); }); it('exports the credentials, clearing dirty flag', function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); var creds = {"a":{"b":1,"c":2}}; credentials.load(creds).then(function() { credentials.add("b",{"foo":"bar"}).then(function() { - credentials.dirty().should.be.true; + credentials.dirty().should.be.true(); credentials.export().then(function(exported) { exported.should.eql(creds); - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); done(); }) }); @@ -81,14 +99,17 @@ describe('red/runtime/nodes/credentials', function() { describe("#clean",function() { it("removes credentials of unknown nodes",function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); var creds = {"a":{"b":1,"c":2},"b":{"d":3}}; credentials.load(creds).then(function() { - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); should.exist(credentials.get("a")); should.exist(credentials.get("b")); credentials.clean([{id:"b"}]).then(function() { - credentials.dirty().should.be.true; + credentials.dirty().should.be.true(); should.not.exist(credentials.get("a")); should.exist(credentials.get("b")); done(); @@ -96,14 +117,17 @@ describe('red/runtime/nodes/credentials', function() { }); }); it("extracts credentials of known nodes",function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); credentials.register("testNode",{"b":"text","c":"password"}) var creds = {"a":{"b":1,"c":2}}; var newConfig = [{id:"a",type:"testNode",credentials:{"b":"newBValue","c":"newCValue"}}]; credentials.load(creds).then(function() { - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); credentials.clean(newConfig).then(function() { - credentials.dirty().should.be.true; + credentials.dirty().should.be.true(); credentials.get("a").should.have.property('b',"newBValue"); credentials.get("a").should.have.property('c',"newCValue"); should.not.exist(newConfig[0].credentials); @@ -116,7 +140,10 @@ describe('red/runtime/nodes/credentials', function() { }); it('warns if a node has no credential definition', function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); credentials.load({}).then(function() { var node = {id:"node",type:"test",credentials:{ user1:"newUser", @@ -124,7 +151,7 @@ describe('red/runtime/nodes/credentials', function() { }}; sinon.spy(log,"warn"); credentials.extract(node); - log.warn.called.should.be.true; + log.warn.called.should.be.true(); should.not.exist(node.credentials); log.warn.restore(); done(); @@ -132,7 +159,10 @@ describe('red/runtime/nodes/credentials', function() { }) it('extract credential updates in the provided node', function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); var defintion = { user1:{type:"text"}, password1:{type:"password"}, @@ -155,12 +185,12 @@ describe('red/runtime/nodes/credentials', function() { user3:"newUser", password3:"newPassword" }}; - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); credentials.extract(node); node.should.not.have.a.property("credentials"); - credentials.dirty().should.be.true; + credentials.dirty().should.be.true(); var newCreds = credentials.get("node"); newCreds.should.have.a.property("user1","abc"); newCreds.should.have.a.property("password1","123"); @@ -173,14 +203,269 @@ describe('red/runtime/nodes/credentials', function() { }); }); it('extract ignores node without credentials', function(done) { - credentials.init(); + credentials.init({ + log: log, + settings: encryptionDisabledSettings + }); credentials.load({"node":{user1:"abc",password1:"123"}}).then(function() { var node = {id:"node",type:"test"}; - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); credentials.extract(node); - credentials.dirty().should.be.false; + credentials.dirty().should.be.false(); done(); }); }); + + describe("encryption",function() { + var settings = {}; + var runtime = { + log: log, + settings: { + get: function(key) { + return settings[key]; + }, + set: function(key,value) { + settings[key] = value; + return when.resolve(); + }, + delete: function(key) { + delete settings[key]; + return when.resolve(); + } + } + } + it('migrates to encrypted and generates default key', function(done) { + settings = {}; + credentials.init(runtime); + credentials.load({"node":{user1:"abc",password1:"123"}}).then(function() { + settings.should.have.a.property("_credentialSecret"); + settings._credentialSecret.should.have.a.length(64); + credentials.dirty().should.be.true(); + credentials.export().then(function(result) { + result.should.have.a.property("$"); + // reset everything - but with _credentialSecret still set + credentials.init(runtime); + // load the freshly encrypted version + credentials.load(result).then(function() { + should.exist(credentials.get("node")); + done(); + }) + }); + }); + }); + it('uses default key', function(done) { + settings = { + _credentialSecret: "e3a36f47f005bf2aaa51ce3fc6fcaafd79da8d03f2b1a9281f8fb0a285e6255a" + }; + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + should.exist(credentials.get("node")); + credentials.dirty().should.be.false(); + credentials.add("node",{user1:"def",password1:"456"}); + credentials.export().then(function(result) { + result.should.have.a.property("$"); + // reset everything - but with _credentialSecret still set + credentials.init(runtime); + // load the freshly encrypted version + credentials.load(result).then(function() { + should.exist(credentials.get("node")); + credentials.get("node").should.have.a.property("user1","def"); + credentials.get("node").should.have.a.property("password1","456"); + done(); + }) + }); + }); + }); + it('uses user key', function(done) { + settings = { + credentialSecret: "e3a36f47f005bf2aaa51ce3fc6fcaafd79da8d03f2b1a9281f8fb0a285e6255a" + }; + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + credentials.dirty().should.be.false(); + should.exist(credentials.get("node")); + credentials.add("node",{user1:"def",password1:"456"}); + credentials.export().then(function(result) { + result.should.have.a.property("$"); + + // reset everything - but with _credentialSecret still set + credentials.init(runtime); + // load the freshly encrypted version + credentials.load(result).then(function() { + should.exist(credentials.get("node")); + credentials.get("node").should.have.a.property("user1","def"); + credentials.get("node").should.have.a.property("password1","456"); + done(); + }) + }); + }); + }); + it('uses user key - when settings are otherwise unavailable', function(done) { + var runtime = { + log: log, + settings: { + get: function(key) { + if (key === 'credentialSecret') { + return "e3a36f47f005bf2aaa51ce3fc6fcaafd79da8d03f2b1a9281f8fb0a285e6255a"; + } + throw new Error(); + }, + set: function(key,value) { + throw new Error(); + } + } + } + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + should.exist(credentials.get("node")); + credentials.add("node",{user1:"def",password1:"456"}); + credentials.export().then(function(result) { + result.should.have.a.property("$"); + + // reset everything - but with _credentialSecret still set + credentials.init(runtime); + // load the freshly encrypted version + credentials.load(result).then(function() { + should.exist(credentials.get("node")); + credentials.get("node").should.have.a.property("user1","def"); + credentials.get("node").should.have.a.property("password1","456"); + done(); + }) + }); + }); + }); + it('migrates from default key to user key', function(done) { + settings = { + _credentialSecret: "e3a36f47f005bf2aaa51ce3fc6fcaafd79da8d03f2b1a9281f8fb0a285e6255a", + credentialSecret: "aaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbcccccccccccccddddddddddddeeeee" + }; + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + credentials.dirty().should.be.true(); + should.exist(credentials.get("node")); + credentials.export().then(function(result) { + result.should.have.a.property("$"); + settings.should.not.have.a.property("_credentialSecret"); + + // reset everything - but with _credentialSecret still set + credentials.init(runtime); + // load the freshly encrypted version + credentials.load(result).then(function() { + should.exist(credentials.get("node")); + credentials.get("node").should.have.a.property("user1","abc"); + credentials.get("node").should.have.a.property("password1","123"); + done(); + }) + }); + }); + }); + + it('migrates from default key to user key - unencrypted original', function(done) { + settings = { + _credentialSecret: "e3a36f47f005bf2aaa51ce3fc6fcaafd79da8d03f2b1a9281f8fb0a285e6255a", + credentialSecret: "aaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbcccccccccccccddddddddddddeeeee" + }; + // {"node":{user1:"abc",password1:"123"}} + var unencryptedFlows = {"node":{user1:"abc",password1:"123"}}; + credentials.init(runtime); + credentials.load(unencryptedFlows).then(function() { + credentials.dirty().should.be.true(); + should.exist(credentials.get("node")); + credentials.export().then(function(result) { + result.should.have.a.property("$"); + settings.should.not.have.a.property("_credentialSecret"); + + // reset everything - but with _credentialSecret still set + credentials.init(runtime); + // load the freshly encrypted version + credentials.load(result).then(function() { + should.exist(credentials.get("node")); + credentials.get("node").should.have.a.property("user1","abc"); + credentials.get("node").should.have.a.property("password1","123"); + done(); + }) + }); + }); + }); + + it('migrates from default key to unencrypted', function(done) { + settings = { + _credentialSecret: "e3a36f47f005bf2aaa51ce3fc6fcaafd79da8d03f2b1a9281f8fb0a285e6255a", + credentialSecret: false + }; + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + credentials.dirty().should.be.true(); + should.exist(credentials.get("node")); + credentials.export().then(function(result) { + result.should.not.have.a.property("$"); + settings.should.not.have.a.property("_credentialSecret"); + result.should.eql({"node":{user1:"abc",password1:"123"}}); + done(); + }); + }); + }); + it('handles bad default key - resets credentials', function(done) { + settings = { + _credentialSecret: "badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb" + }; + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + credentials.dirty().should.be.true(); + should.not.exist(credentials.get("node")); + done(); + }); + }); + it('handles bad user key - resets credentials', function(done) { + settings = { + credentialSecret: "badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb" + }; + // {"node":{user1:"abc",password1:"123"}} + var cryptedFlows = {"$":"5b89d8209b5158a3c313675561b1a5b5phN1gDBe81Zv98KqS/hVDmc9EKvaKqRIvcyXYvBlFNzzzJtvN7qfw06i"}; + credentials.init(runtime); + credentials.load(cryptedFlows).then(function() { + credentials.dirty().should.be.true(); + should.not.exist(credentials.get("node")); + done(); + }); + }); + + it('handles unavailable settings - leaves creds unencrypted', function(done) { + var runtime = { + log: log, + settings: { + get: function(key) { + throw new Error(); + }, + set: function(key,value) { + throw new Error(); + } + } + } + // {"node":{user1:"abc",password1:"123"}} + credentials.init(runtime); + credentials.load({"node":{user1:"abc",password1:"123"}}).then(function() { + credentials.dirty().should.be.false(); + should.exist(credentials.get("node")); + credentials.export().then(function(result) { + result.should.not.have.a.property("$"); + result.should.have.a.property("node"); + done(); + }); + }); + }); + }) }) diff --git a/test/red/runtime/nodes/index_spec.js b/test/red/runtime/nodes/index_spec.js index 565f201a2..d02372970 100644 --- a/test/red/runtime/nodes/index_spec.js +++ b/test/red/runtime/nodes/index_spec.js @@ -50,12 +50,14 @@ describe("red/nodes/index", function() { }; var settings = { - available: function() { return false } + available: function() { return false }, + get: function() { return false } }; var runtime = { settings: settings, - storage: storage + storage: storage, + log: {debug:function(){},warn:function(){}} }; function TestNode(n) {