Merge pull request #185 from indexzero/crypto

[wip] Allow for "secure" option to be passed to `nconf.stores.File`
This commit is contained in:
Charlie Robbins 2015-09-20 01:13:26 -07:00
commit d4ebf49908
7 changed files with 232 additions and 67 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ test/fixtures/*.json
!test/fixtures/malformed.json !test/fixtures/malformed.json
!test/fixtures/bom.json !test/fixtures/bom.json
!test/fixtures/no-bom.json !test/fixtures/no-bom.json
!test/fixtures/secure.json
node_modules/ node_modules/
node_modules/* node_modules/*
npm-debug.log npm-debug.log

View file

@ -1,14 +1,28 @@
language: node_js
sudo: false sudo: false
before_install: language: node_js
- '[ "${TRAVIS_NODE_VERSION}" != "0.8" ] || npm install -g npm@1.4.28'
- npm install -g npm@latest
node_js: node_js:
- "0.8" - "0.8"
- "0.10" - "0.10"
- "0.11"
- "0.12" - "0.12"
- "iojs" - "4.1"
before_install:
- '[ "${TRAVIS_NODE_VERSION}" != "0.8" ] || travis_retry npm install -g npm@1.4.28'
- '[ "${TRAVIS_NODE_VERSION}" == "0.8" ] || travis_retry npm install -g npm@2.14.2'
- travis_retry npm install
before_install:
- travis_retry npm install -g npm@2.5.1
- travis_retry npm install
script:
- npm test
matrix:
allow_failures:
- node_js: "0.8"
- node_js: "0.10"
notifications: notifications:
email: email:
- travis@nodejitsu.com - travis@nodejitsu.com

View file

@ -20,8 +20,8 @@ Using nconf is easy; it is designed to be a simple key-value store with support
// 3. A file located at 'path/to/config.json' // 3. A file located at 'path/to/config.json'
// //
nconf.argv() nconf.argv()
.env() .env()
.file({ file: 'path/to/config.json' }); .file({ file: 'path/to/config.json' });
// //
// Set a few variables on `nconf`. // Set a few variables on `nconf`.
@ -216,7 +216,9 @@ Loads a given object literal into the configuration hierarchy. Both `nconf.defau
``` ```
### File ### File
Based on the Memory store, but provides additional methods `.save()` and `.load()` which allow you to read your configuration to and from file. As with the Memory store, all method calls are synchronous with the exception of `.save()` and `.load()` which take callback functions. It is important to note that setting keys in the File engine will not be persisted to disk until a call to `.save()` is made. Note a custom key must be supplied as the first parameter for hierarchy to work if multiple files are used. Based on the Memory store, but provides additional methods `.save()` and `.load()` which allow you to read your configuration to and from file. As with the Memory store, all method calls are synchronous with the exception of `.save()` and `.load()` which take callback functions.
It is important to note that setting keys in the File engine will not be persisted to disk until a call to `.save()` is made. Note a custom key must be supplied as the first parameter for hierarchy to work if multiple files are used.
``` js ``` js
nconf.file('path/to/your/config.json'); nconf.file('path/to/your/config.json');
@ -229,6 +231,35 @@ The file store is also extensible for multiple file formats, defaulting to `JSON
If the file does not exist at the provided path, the store will simply be empty. If the file does not exist at the provided path, the store will simply be empty.
#### Encrypting file contents
As of `nconf@0.8.0` it is now possible to encrypt and decrypt file contents using the `secure` option:
``` js
nconf.file('secure-file', {
file: 'path/to/secure-file.json',
secure: {
secret: 'super-secretzzz-keyzz',
alg: 'aes-256-ctr'
}
})
```
This will encrypt each key using [`crypto.createCipher`](https://nodejs.org/api/crypto.html#crypto_crypto_createcipher_algorithm_password), defaulting to `aes-256-ctr`. The encrypted file contents will look like this:
```
{
"config-key-name": {
"alg": "aes-256-ctr", // cipher used
"value": "af07fbcf" // encrypted contents
},
"another-config-key": {
"alg": "aes-256-ctr", // cipher used
"value": "e310f6d94f13" // encrypted contents
},
}
```
### Redis ### Redis
There is a separate Redis-based store available through [nconf-redis][0]. To install and use this store simply: There is a separate Redis-based store available through [nconf-redis][0]. To install and use this store simply:
@ -252,22 +283,8 @@ Once installing both `nconf` and `nconf-redis`, you must require both modules to
``` ```
## Installation ## Installation
### Installing npm (node package manager)
``` ```
curl http://npmjs.org/install.sh | sh npm install nconf --save
```
### Installing nconf
```
[sudo] npm install nconf
```
## More Documentation
There is more documentation available through docco. I haven't gotten around to making a gh-pages branch so in the meantime if you clone the repository you can view the docs:
```
open docs/nconf.html
``` ```
## Run Tests ## Run Tests

View file

@ -8,8 +8,12 @@
var fs = require('fs'), var fs = require('fs'),
async = require('async'), async = require('async'),
common = require('./nconf/common'), common = require('./nconf/common'),
Provider = require('./nconf/provider').Provider, Provider = require('./nconf/provider').Provider;
nconf = module.exports = new Provider();
//
// `nconf` is by default an instance of `nconf.Provider`.
//
var nconf = module.exports = new Provider();
// //
// Expose the version from the package.json // Expose the version from the package.json

View file

@ -5,7 +5,8 @@
* *
*/ */
var fs = require('fs'), var crypto = require('crypto'),
fs = require('fs'),
path = require('path'), path = require('path'),
util = require('util'), util = require('util'),
formats = require('../formats'), formats = require('../formats'),
@ -26,11 +27,29 @@ var File = exports.File = function (options) {
Memory.call(this, options); Memory.call(this, options);
this.type = 'file'; this.type = 'file';
this.file = options.file; this.file = options.file;
this.dir = options.dir || process.cwd(); this.dir = options.dir || process.cwd();
this.format = options.format || formats.json; this.format = options.format || formats.json;
this.json_spacing = options.json_spacing || 2; this.secure = options.secure;
this.spacing = options.json_spacing
|| options.spacing
|| 2;
if (this.secure) {
this.secure = typeof this.secure === 'string'
? { secret: this.secure }
: this.secure;
this.secure.alg = this.secure.alg || 'aes-256-ctr';
if (this.secure.secretPath) {
this.secret = fs.readFileSync(this.secure.secretPath, 'utf8');
}
if (!this.secure.secret) {
throw new Error('secure.secret option is required');
}
}
if (options.search) { if (options.search) {
this.search(this.dir); this.search(this.dir);
@ -53,9 +72,7 @@ File.prototype.save = function (value, callback) {
value = null; value = null;
} }
fs.writeFile(this.file, this.format.stringify(this.store, null, this.json_spacing), function (err) { fs.writeFile(this.file, this.stringify(), callback);
return err ? callback(err) : callback();
});
}; };
// //
@ -66,12 +83,7 @@ File.prototype.save = function (value, callback) {
// using the format specified by `this.format` synchronously. // using the format specified by `this.format` synchronously.
// //
File.prototype.saveSync = function (value) { File.prototype.saveSync = function (value) {
try { fs.writeFileSync(this.file, this.stringify());
fs.writeFileSync(this.file, this.format.stringify(this.store, null, this.json_spacing));
}
catch (ex) {
throw(ex);
}
return this.store; return this.store;
}; };
@ -97,12 +109,13 @@ File.prototype.load = function (callback) {
} }
try { try {
//deals with string that include BOM // Deals with string that include BOM
var stringData = data.toString(); var stringData = data.toString();
if (stringData.charAt(0) === '\uFEFF') {
stringData = stringData.substr(1);
}
if (stringData.charAt(0) === '\uFEFF') stringData = stringData.substr(1); self.store = self.parse(stringData);
self.store = self.format.parse(stringData);
} }
catch (ex) { catch (ex) {
return callback(new Error("Error parsing your configuration file: [" + self.file + ']: ' + ex.message)); return callback(new Error("Error parsing your configuration file: [" + self.file + ']: ' + ex.message));
@ -119,30 +132,81 @@ File.prototype.load = function (callback) {
// and responds appropriately. // and responds appropriately.
// //
File.prototype.loadSync = function () { File.prototype.loadSync = function () {
var data, self = this; if (!existsSync(this.file)) {
this.store = {};
if (!existsSync(self.file)) { return this.store;
self.store = {};
data = {};
}
else {
//
// Else, the path exists, read it from disk
//
try {
//deals with file that include BOM
var fileData = fs.readFileSync(this.file, 'utf8');
if (fileData.charAt(0) === '\uFEFF') fileData = fileData.substr(1);
data = this.format.parse(fileData);
this.store = data;
}
catch (ex) {
throw new Error("Error parsing your configuration file: [" + self.file + ']: ' + ex.message);
}
} }
return data; //
// Else, the path exists, read it from disk
//
try {
// Deals with file that include BOM
var fileData = fs.readFileSync(this.file, 'utf8');
if (fileData.charAt(0) === '\uFEFF') {
fileData = fileData.substr(1);
}
this.store = this.parse(fileData);
}
catch (ex) {
throw new Error("Error parsing your configuration file: [" + this.file + ']: ' + ex.message);
}
return this.store;
};
//
// ### function stringify ()
// Returns an encrypted version of the contents IIF
// `this.secure` is enabled
//
File.prototype.stringify = function () {
var data = this.store,
self = this;
if (this.secure) {
data = Object.keys(data).reduce(function (acc, key) {
var value = self.format.stringify(data[key]);
acc[key] = {
alg: self.secure.alg,
value: cipherConvert(value, {
alg: self.secure.alg,
secret: self.secure.secret,
encs: { input: 'utf8', output: 'hex' }
})
}
return acc;
}, {});
}
return this.format.stringify(data, null, this.spacing);
};
//
// ### function parse (contents)
// Returns a decrypted version of the contents IFF
// `this.secure` is enabled.
//
File.prototype.parse = function (contents) {
var parsed = this.format.parse(contents),
self = this;
if (!this.secure) {
return parsed;
}
return Object.keys(parsed).reduce(function (acc, key) {
var decrypted = cipherConvert(parsed[key].value, {
alg: parsed[key].alg || self.secure.alg,
secret: self.secure.secret,
encs: { input: 'hex', output: 'utf8' }
});
acc[key] = self.format.parse(decrypted);
return acc;
}, {});
}; };
// //
@ -235,3 +299,15 @@ File.prototype.search = function (base) {
return fullpath; return fullpath;
}; };
//
// ### function cipherConvert (contents, opts)
// Returns the result of the cipher operation
// on the contents contents.
//
function cipherConvert(contents, opts) {
var encs = opts.encs;
var cipher = crypto.createCipher(opts.alg, opts.secret);
return cipher.update(contents, encs.input, encs.output)
+ cipher.final(encs.output);
}

18
test/fixtures/secure.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"isNull": {
"alg": "aes-256-ctr",
"value": "af07fbcf"
},
"literal": {
"alg": "aes-256-ctr",
"value": "e310f6d94f13"
},
"arr": {
"alg": "aes-256-ctr",
"value": "9a78b783175e69bb8f3458042b1c098d8ed9613410fac185b3735099224f8fe4ece0f0da8decfddbbf0eab3b7c391c47772b5441"
},
"obj": {
"alg": "aes-256-ctr",
"value": "ba78b783175968add93a680429424ae4cf957d2916ebcfa399730bb17200ddb0ecacb183c1b1ebcd950ced76726964062e74643c995c47372bfb1311bee8f65bbeb5a1d9426537a6d83635220ec7934e1d7cc187f7218cd4afadfa2f107fb42c232d80d95c160ee704fa8e922998b0b3e47ec579dd0baef7cae6d7dbaa203d732adb5cff22b80d810d7191237999cd8dc528d8f2201ae128a9f9e2df96d1a816aa73e3e6b8e6246cd98b454e453b36f43f9117cb4af8fa85429a92"
}
}

View file

@ -222,5 +222,40 @@ vows.describe('nconf/stores/file').addBatch({
} }
} }
} }
}).addBatch({
"When using the nconf file store": {
topic: function () {
var secureStore = new nconf.File({
file: path.join(__dirname, '..', 'fixtures', 'secure.json'),
secure: 'super-secretzzz'
});
secureStore.store = data;
return secureStore;
},
"the stringify() method should encrypt properly": function (store) {
var contents = JSON.parse(store.stringify());
Object.keys(data).forEach(function (key) {
assert.isObject(contents[key]);
assert.isString(contents[key].value);
assert.equal(contents[key].alg, 'aes-256-ctr');
});
},
"the parse() method should decrypt properly": function (store) {
var contents = store.stringify();
var parsed = store.parse(contents);
assert.deepEqual(parsed, data);
},
"the load() method should decrypt properly": function (store) {
store.load(function (err, loaded) {
assert.isNull(err);
assert.deepEqual(loaded, data);
});
},
"the loadSync() method should decrypt properly": function (store) {
var loaded = store.loadSync()
assert.deepEqual(loaded, data);
}
}
}).export(module); }).export(module);