Adds keystore for securely storing settings (#14714)

Introduces bin/kibana-keystore providing create, list, add, and remove actions.

Settings stored within the keystore will be loaded at runtime.

Signed-off-by: Tyler Smalley <tyler.smalley@elastic.co>
This commit is contained in:
Tyler Smalley 2017-11-13 10:25:10 -08:00 committed by Tyler Smalley
parent 7b9eb6da7b
commit 3dff020add
28 changed files with 1081 additions and 6 deletions

24
bin/kibana-keystore Executable file
View file

@ -0,0 +1,24 @@
#!/bin/sh
SCRIPT=$0
# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path.
while [ -h "$SCRIPT" ] ; do
ls=$(ls -ld "$SCRIPT")
# Drop everything prior to ->
link=$(expr "$ls" : '.*-> \(.*\)$')
if expr "$link" : '/.*' > /dev/null; then
SCRIPT="$link"
else
SCRIPT=$(dirname "$SCRIPT")/"$link"
fi
done
DIR="$(dirname "${SCRIPT}")/.."
NODE="${DIR}/node/bin/node"
test -x "$NODE" || NODE=$(which node)
if [ ! -x "$NODE" ]; then
echo "unable to find usable node.js executable."
exit 1
fi
"${NODE}" "${DIR}/src/cli_keystore" "$@"

29
bin/kibana-keystore.bat Normal file
View file

@ -0,0 +1,29 @@
@echo off
SETLOCAL
set SCRIPT_DIR=%~dp0
for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI
set NODE=%DIR%\node\node.exe
WHERE /Q node
IF %ERRORLEVEL% EQU 0 (
for /f "delims=" %%i in ('WHERE node') do set SYS_NODE=%%i
)
If Not Exist "%NODE%" (
IF Exist "%SYS_NODE%" (
set "NODE=%SYS_NODE%"
) else (
Echo unable to find usable node.js executable.
Exit /B 1
)
)
TITLE Kibana Keystore
"%NODE%" "%DIR%\src\cli_keystore" %*
:finally
ENDLOCAL

View file

@ -50,6 +50,8 @@ include::setup/install.asciidoc[]
include::setup/settings.asciidoc[]
include::setup/secure-settings.asciidoc[]
include::setup/docker.asciidoc[]
include::setup/access.asciidoc[]

View file

@ -0,0 +1,64 @@
[[secure-settings]]
=== Secure Settings
Some settings are sensitive, and relying on filesystem permissions to protect
their values is not sufficient. For this use case, Kibana provides a
keystore, and the `kibana-keystore` tool to manage the settings in the keystore.
NOTE: All commands here should be run as the user which will run Kibana.
[float]
[[creating-keystore]]
=== Creating the keystore
To create the `kibana.keystore`, use the `create` command:
[source,sh]
----------------------------------------------------------------
bin/kibana-keystore create
----------------------------------------------------------------
The file `kibana.keystore` will be created in the directory defined by the
`path.data` configuration setting.
[float]
[[list-settings]]
=== Listing settings in the keystore
A list of the settings in the keystore is available with the `list` command:
[source,sh]
----------------------------------------------------------------
bin/kibana-keystore list
----------------------------------------------------------------
[float]
[[add-string-to-keystore]]
=== Adding string settings
Sensitive string settings, like authentication credentials for Elasticsearch
can be added using the `add` command:
[source,sh]
----------------------------------------------------------------
bin/kibana-keystore add the.setting.name.to.set
----------------------------------------------------------------
The tool will prompt for the value of the setting. To pass the value
through stdin, use the `--stdin` flag:
[source,sh]
----------------------------------------------------------------
cat /file/containing/setting/value | bin/kibana-keystore add --stdin the.setting.name.to.set
----------------------------------------------------------------
[float]
[[remove-settings]]
=== Removing settings
To remove a setting from the keystore, use the `remove` command:
[source,sh]
----------------------------------------------------------------
bin/kibana-keystore remove the.setting.name.to.remove
----------------------------------------------------------------

View file

@ -1,6 +1,6 @@
import expect from 'expect.js';
import { join, relative, resolve } from 'path';
import readYamlConfig from '../read_yaml_config';
import { readYamlConfig } from '../read_yaml_config';
function fixture(name) {
return resolve(__dirname, 'fixtures', name);

View file

@ -3,7 +3,7 @@ import { writeFileSync } from 'fs';
import { relative, resolve } from 'path';
import { safeDump } from 'js-yaml';
import es from 'event-stream';
import readYamlConfig from '../read_yaml_config';
import { readYamlConfig } from '../read_yaml_config';
import expect from 'expect.js';
const testConfigFile = follow(`fixtures/reload_logging_config/kibana.test.yml`);

View file

@ -0,0 +1,26 @@
import { join } from 'path';
import { set } from 'lodash';
import { Keystore } from '../../server/keystore';
import { getData } from '../../server/path';
export function loadKeystore() {
const path = join(getData(), 'kibana.keystore');
const keystore = new Keystore(path);
keystore.load();
return keystore;
}
export function readKeystore() {
const keystore = loadKeystore();
const keys = Object.keys(keystore.data);
const data = {};
keys.forEach(key => {
set(data, key, keystore.data[key]);
});
return data;
}

View file

@ -24,7 +24,7 @@ export function merge(sources) {
}, {});
}
export default function (paths) {
export function readYamlConfig(paths) {
const files = [].concat(paths || []);
const yamls = files.map(path => safeLoad(read(path, 'utf8')));
return merge(yamls);

View file

@ -2,9 +2,11 @@ import _ from 'lodash';
import { statSync } from 'fs';
import { isWorker } from 'cluster';
import { resolve } from 'path';
import { fromRoot } from '../../utils';
import { getConfig } from '../../server/path';
import readYamlConfig from './read_yaml_config';
import { readYamlConfig } from './read_yaml_config';
import { readKeystore } from './read_keystore';
import { DEV_SSL_CERT_PATH, DEV_SSL_KEY_PATH } from '../dev_ssl';
@ -67,6 +69,7 @@ function readServerSettings(opts, extraCliOptions) {
opts.pluginPath
)));
merge(readKeystore());
merge(extraCliOptions);
return settings;

View file

@ -0,0 +1,134 @@
import expect from 'expect.js';
import sinon from 'sinon';
import mockFs from 'mock-fs';
import { PassThrough } from 'stream';
import { Keystore } from '../../server/keystore';
import { add } from '../add';
import Logger from '../../cli_plugin/lib/logger';
import * as prompt from '../../server/utils/prompt';
describe('Kibana keystore', () => {
describe('add', () => {
const sandbox = sinon.sandbox.create();
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
beforeEach(() => {
mockFs({
'/data': {
'test.keystore': JSON.stringify(keystoreData),
}
});
sandbox.stub(prompt, 'confirm');
sandbox.stub(prompt, 'question');
sandbox.stub(Logger.prototype, 'log');
sandbox.stub(Logger.prototype, 'error');
});
afterEach(() => {
mockFs.restore();
sandbox.restore();
});
it('returns an error for a nonexistent keystore', async () => {
const keystore = new Keystore('/data/nonexistent.keystore');
const message = 'ERROR: Kibana keystore not found. Use \'create\' command to create one.';
await add(keystore, 'foo');
sinon.assert.calledOnce(Logger.prototype.error);
sinon.assert.calledWith(Logger.prototype.error, message);
});
it('does not attempt to create a keystore', async () => {
const keystore = new Keystore('/data/nonexistent.keystore');
sandbox.stub(keystore, 'save');
await add(keystore, 'foo');
sinon.assert.notCalled(keystore.save);
});
it('prompts for existing key', async () => {
prompt.confirm.returns(Promise.resolve(true));
prompt.question.returns(Promise.resolve('bar'));
const keystore = new Keystore('/data/test.keystore');
await add(keystore, 'a2');
sinon.assert.calledOnce(prompt.confirm);
sinon.assert.calledOnce(prompt.question);
const { args } = prompt.confirm.getCall(0);
expect(args[0]).to.eql('Setting a2 already exists. Overwrite?');
});
it('aborts if overwrite is denied', async () => {
prompt.confirm.returns(Promise.resolve(false));
const keystore = new Keystore('/data/test.keystore');
await add(keystore, 'a2');
sinon.assert.notCalled(prompt.question);
sinon.assert.calledOnce(Logger.prototype.log);
sinon.assert.calledWith(Logger.prototype.log, 'Exiting without modifying keystore.');
});
it('overwrites without prompt if force is supplied', async () => {
prompt.question.returns(Promise.resolve('bar'));
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
await add(keystore, 'a2', { force: true });
sinon.assert.notCalled(prompt.confirm);
sinon.assert.calledOnce(keystore.save);
});
it('trims value', async () => {
prompt.question.returns(Promise.resolve('bar\n'));
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
await add(keystore, 'foo');
expect(keystore.data.foo).to.eql('bar');
});
it('persists updated keystore', async () => {
prompt.question.returns(Promise.resolve('bar\n'));
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
await add(keystore, 'foo');
sinon.assert.calledOnce(keystore.save);
});
it('accepts stdin', async () => {
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
const stdin = new PassThrough();
process.nextTick(() => {
stdin.write('kibana\n');
stdin.end();
});
await add(keystore, 'foo', { stdin });
expect(keystore.data.foo).to.eql('kibana');
});
});
});

View file

@ -0,0 +1,79 @@
import expect from 'expect.js';
import sinon from 'sinon';
import mockFs from 'mock-fs';
import { Keystore } from '../../server/keystore';
import { create } from '../create';
import Logger from '../../cli_plugin/lib/logger';
import * as prompt from '../../server/utils/prompt';
describe('Kibana keystore', () => {
describe('create', () => {
const sandbox = sinon.sandbox.create();
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
beforeEach(() => {
mockFs({
'/data': {
'test.keystore': JSON.stringify(keystoreData),
}
});
sandbox.stub(Logger.prototype, 'log');
sandbox.stub(Logger.prototype, 'error');
});
afterEach(() => {
mockFs.restore();
sandbox.restore();
});
it('creates keystore file', async () => {
const keystore = new Keystore('/data/foo.keystore');
sandbox.stub(keystore, 'save');
await create(keystore);
sinon.assert.calledOnce(keystore.save);
});
it('logs successful keystore creating', async () => {
const path = '/data/foo.keystore';
const keystore = new Keystore(path);
await create(keystore);
sinon.assert.calledOnce(Logger.prototype.log);
sinon.assert.calledWith(Logger.prototype.log, `Created Kibana keystore in ${path}`);
});
it('prompts for overwrite', async () => {
sandbox.stub(prompt, 'confirm').returns(Promise.resolve(true));
const keystore = new Keystore('/data/test.keystore');
await create(keystore);
sinon.assert.calledOnce(prompt.confirm);
const { args } = prompt.confirm.getCall(0);
expect(args[0]).to.eql('A Kibana keystore already exists. Overwrite?');
});
it('aborts if overwrite is denied', async () => {
sandbox.stub(prompt, 'confirm').returns(Promise.resolve(false));
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
await create(keystore);
sinon.assert.calledOnce(Logger.prototype.log);
sinon.assert.calledWith(Logger.prototype.log, 'Exiting without modifying keystore.');
sinon.assert.notCalled(keystore.save);
});
});
});

View file

@ -0,0 +1,48 @@
import sinon from 'sinon';
import mockFs from 'mock-fs';
import { Keystore } from '../../server/keystore';
import { list } from '../list';
import Logger from '../../cli_plugin/lib/logger';
describe('Kibana keystore', () => {
describe('list', () => {
const sandbox = sinon.sandbox.create();
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
beforeEach(() => {
mockFs({
'/data': {
'test.keystore': JSON.stringify(keystoreData),
}
});
sandbox.stub(Logger.prototype, 'log');
sandbox.stub(Logger.prototype, 'error');
});
afterEach(() => {
mockFs.restore();
sandbox.restore();
});
it('outputs keys', () => {
const keystore = new Keystore('/data/test.keystore');
list(keystore);
sinon.assert.calledOnce(Logger.prototype.log);
sinon.assert.calledWith(Logger.prototype.log, 'a1.b2.c3\na2');
});
it('handles a nonexistent keystore', () => {
const keystore = new Keystore('/data/nonexistent.keystore');
list(keystore);
sinon.assert.calledOnce(Logger.prototype.log);
sinon.assert.calledWith(Logger.prototype.log, '');
});
});
});

View file

@ -0,0 +1,46 @@
import expect from 'expect.js';
import sinon from 'sinon';
import mockFs from 'mock-fs';
import { Keystore } from '../../server/keystore';
import { remove } from '../remove';
describe('Kibana keystore', () => {
describe('remove', () => {
const sandbox = sinon.sandbox.create();
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
beforeEach(() => {
mockFs({
'/data': {
'test.keystore': JSON.stringify(keystoreData),
}
});
});
afterEach(() => {
mockFs.restore();
sandbox.restore();
});
it('removes key', () => {
const keystore = new Keystore('/data/test.keystore');
remove(keystore, 'a2');
expect(keystore.data).to.eql({ 'a1.b2.c3': 'foo' });
});
it('persists the keystore', () => {
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
remove(keystore, 'a2');
sinon.assert.calledOnce(keystore.save);
});
});
});

50
src/cli_keystore/add.js Normal file
View file

@ -0,0 +1,50 @@
import Logger from '../cli_plugin/lib/logger';
import { confirm, question } from '../server/utils';
import { createPromiseFromStreams, createConcatStream } from '../utils';
/**
* @param {Keystore} keystore
* @param {String} key
* @param {Object|null} options
* @property {Boolean} options.force - if true, will overwrite without prompting
* @property {Stream|Boolean} options.stdin - if true, uses process.stdin
*/
export async function add(keystore, key, options = {}) {
const logger = new Logger(options);
let value;
if (!keystore.exists()) {
return logger.error('ERROR: Kibana keystore not found. Use \'create\' command to create one.');
}
if (!options.force && keystore.has(key)) {
const overwrite = await confirm(`Setting ${key} already exists. Overwrite?`);
if (!overwrite) {
return logger.log('Exiting without modifying keystore.');
}
}
if (options.stdin) {
value = await createPromiseFromStreams([
options.stdin || process.stdin,
createConcatStream('')
]);
} else {
value = await question(`Enter value for ${key}`, { mask: '*' });
}
keystore.add(key, value.trim());
keystore.save();
}
export function addCli(program, keystore) {
program
.command('add <key>')
.description('Add a string setting to the keystore')
.option('-f, --force', 'overwrite existing setting without prompting')
.option('-x, --stdin', 'read setting value from stdin')
.option('-s, --silent', 'prevent all logging')
.action(add.bind(null, keystore));
}

View file

@ -0,0 +1,31 @@
import { join } from 'path';
import { pkg } from '../utils';
import Command from '../cli/command';
import { getData } from '../server/path';
import { Keystore } from '../server/keystore';
const path = join(getData(), 'kibana.keystore');
const keystore = new Keystore(path);
import { createCli } from './create';
import { listCli } from './list';
import { addCli } from './add';
import { removeCli } from './remove';
const program = new Command('bin/kibana-keystore');
program
.version(pkg.version)
.description('A tool for managing settings stored in the Kibana keystore');
createCli(program, keystore);
listCli(program, keystore);
addCli(program, keystore);
removeCli(program, keystore);
program.parse(process.argv);
if (!program.args.length) {
program.help();
}

View file

@ -0,0 +1,26 @@
import Logger from '../cli_plugin/lib/logger';
import { confirm } from '../server/utils';
export async function create(keystore, command, options) {
const logger = new Logger(options);
if (keystore.exists()) {
const overwrite = await confirm('A Kibana keystore already exists. Overwrite?');
if (!overwrite) {
return logger.log('Exiting without modifying keystore.');
}
}
keystore.save();
logger.log(`Created Kibana keystore in ${keystore.path}`);
}
export function createCli(program, keystore) {
program
.command('create')
.description('Creates a new Kibana keystore')
.option('-s, --silent', 'prevent all logging')
.action(create.bind(null, keystore));
}

View file

@ -0,0 +1,2 @@
require('../babel-register');
require('./cli_keystore');

16
src/cli_keystore/list.js Normal file
View file

@ -0,0 +1,16 @@
import Logger from '../cli_plugin/lib/logger';
export function list(keystore, command, options = {}) {
const logger = new Logger(options);
const keys = keystore.keys();
logger.log(keys.join('\n'));
}
export function listCli(program, keystore) {
program
.command('list')
.description('List entries in the keystore')
.option('-s, --silent', 'prevent all logging')
.action(list.bind(null, keystore));
}

View file

@ -0,0 +1,12 @@
export async function remove(keystore, key) {
keystore.remove(key);
keystore.save();
}
export function removeCli(program, keystore) {
program
.command('remove <key>')
.description('Remove a setting from the keystore')
.option('-s, --silent', 'prevent all logging')
.action(remove.bind(null, keystore));
}

View file

@ -2,7 +2,7 @@ import _ from 'lodash';
import path from 'path';
import { fromRoot } from '../../utils';
import KbnServer from '../../server/kbn_server';
import readYamlConfig from '../../cli/serve/read_yaml_config';
import { readYamlConfig } from '../../cli/serve/read_yaml_config';
import { versionSatisfies, cleanVersion } from '../../utils/version';
import { statSync } from 'fs';

View file

@ -3,7 +3,7 @@
*/
export default class Logger {
constructor(settings) {
constructor(settings = {}) {
this.previousLineEnded = true;
this.silent = !!settings.silent;
this.quiet = !!settings.quiet;

View file

@ -0,0 +1,193 @@
import expect from 'expect.js';
import mockFs from 'mock-fs';
import sinon from 'sinon';
import { readFileSync } from 'fs';
import { Keystore } from '../keystore';
describe('Keystore', () => {
const sandbox = sinon.sandbox.create();
const protoctedKeystoreData = '1:4BnWfydL8NwFIQJg+VQKe0jlIs7uXtty6+++yaWPbSB'
+ 'KIX3d9nPfQ20K1C6Xh26E/gMJAQ9jh7BxK0+W3lt/iDJBJn44wqX3pQ0189iGkNBL0ibDCc'
+ 'tz4mRy6+hqwiLxiukpH8ELAJsff8LNNHr+gNzX/2k/GvB7nQ==';
const unprotectedKeystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDy'
+ 'fGfJSy4mHBBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqh'
+ 'I4lzJ9MRy21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
beforeEach(() => {
mockFs({
'/data': {
'protected.keystore': protoctedKeystoreData,
'unprotected.keystore': unprotectedKeystoreData,
},
'/inaccessible': mockFs.directory({
mode: '0000',
})
});
});
afterEach(() => {
mockFs.restore();
sandbox.restore();
});
describe('save', () => {
it('thows permission denied', () => {
const path = '/inaccessible/test.keystore';
try {
const keystore = new Keystore(path);
keystore.save();
expect().fail('should throw error');
} catch(e) {
expect(e.code).to.eql('EACCES');
}
});
it('creates keystore with version', () => {
const path = '/data/test.keystore';
const keystore = new Keystore(path);
keystore.save();
const fileBuffer = readFileSync(path);
const contents = fileBuffer.toString();
const [version, data] = contents.split(':');
expect(version).to.eql(1);
expect(data.length).to.be.greaterThan(100);
});
});
describe('load', () => {
it('is called on initialization', () => {
const load = sandbox.spy(Keystore.prototype, 'load');
new Keystore('/data/protected.keystore', 'changeme');
expect(load.calledOnce).to.be(true);
});
it('can load a password protected keystore', () => {
const keystore = new Keystore('/data/protected.keystore', 'changeme');
expect(keystore.data).to.eql({ 'a1.b2.c3': 'foo', 'a2': 'bar' });
});
it('throws unable to read keystore', () => {
try {
new Keystore('/data/protected.keystore', 'wrongpassword');
expect().fail('should throw error');
} catch(e) {
expect(e).to.be.a(Keystore.errors.UnableToReadKeystore);
}
});
it('gracefully handles keystore not found', () => {
new Keystore('/data/nonexistent.keystore');
});
});
describe('keys', () => {
it('lists object keys', () => {
const keystore = new Keystore('/data/unprotected.keystore');
const keys = keystore.keys();
expect(keys).to.eql(['a1.b2.c3', 'a2']);
});
});
describe('has', () => {
it('returns true if key exists', () => {
const keystore = new Keystore('/data/unprotected.keystore');
expect(keystore.has('a2')).to.be(true);
});
it('returns false if key does not exist', () => {
const keystore = new Keystore('/data/unprotected.keystore');
expect(keystore.has('invalid')).to.be(false);
});
});
describe('add', () => {
it('adds a key/value pair', () => {
const keystore = new Keystore('/data/unprotected.keystore');
keystore.add('a3', 'baz');
expect(keystore.data).to.eql({
'a1.b2.c3': 'foo',
'a2': 'bar',
'a3': 'baz',
});
});
});
describe('remove', () => {
it('removes a key/value pair', () => {
const keystore = new Keystore('/data/unprotected.keystore');
keystore.remove('a1.b2.c3');
expect(keystore.data).to.eql({
'a2': 'bar',
});
});
});
describe('encrypt', () => {
it('has randomness ', () => {
const text = 'foo';
const password = 'changeme';
const dataOne = Keystore.encrypt(text, password);
const dataTwo = Keystore.encrypt(text, password);
expect(dataOne).to.not.eql(dataTwo);
});
it('can immediately be decrypted', () => {
const password = 'changeme';
const secretText = 'foo';
const data = Keystore.encrypt(secretText, password);
const text = Keystore.decrypt(data, password);
expect(text).to.eql(secretText);
});
});
describe('decrypt', () => {
const text = 'foo';
const password = 'changeme';
const ciphertext = 'ctvRsD0l0u958QoPuINQX+wgspbXt2+7IJ7gNbCND2dCGZxYOCwMH9'
+ 'MEdZZG4cevSrnhYOaxh24POFhtisSdCSlLWsKNQU8NK1zqNQ3RRP8HxayZJB7ly9uOLbDS+'
+ 'Ew=';
it('can decrypt data', () => {
const data = Keystore.decrypt(ciphertext, password);
expect(data).to.eql(text);
});
it('throws error for invalid password', () => {
try {
Keystore.decrypt(ciphertext, 'invalid');
expect().fail('should throw error');
} catch(e) {
expect(e).to.be.a(Keystore.errors.UnableToReadKeystore);
}
});
it('throws error for corrupt ciphertext', () => {
try {
Keystore.decrypt('thisisinvalid', password);
expect().fail('should throw error');
} catch(e) {
expect(e).to.be.a(Keystore.errors.UnableToReadKeystore);
}
});
});
});

View file

@ -0,0 +1,12 @@
class KeystoreError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}
export class UnableToReadKeystore extends KeystoreError {
constructor(message) {
super(message || 'unable to read keystore');
}
}

View file

@ -0,0 +1 @@
export { Keystore } from './keystore';

View file

@ -0,0 +1,103 @@
import { writeFileSync, readFileSync, existsSync } from 'fs';
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
import * as errors from './errors';
const VERSION = 1;
const ALGORITHM = 'aes-256-gcm';
const ITERATIONS = 10000;
export class Keystore {
constructor(path, password = '') {
this.path = path;
this.data = {};
this.password = password;
this.load();
}
static errors = errors;
static encrypt(text, password = '') {
const iv = randomBytes(12);
const salt = randomBytes(64);
const key = pbkdf2Sync(password, salt, ITERATIONS, 32, 'sha512');
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([salt, iv, tag, ciphertext]).toString('base64');
}
static decrypt(data, password = '') {
try {
const bData = new Buffer(data, 'base64');
// convert data to buffers
const salt = bData.slice(0, 64);
const iv = bData.slice(64, 76);
const tag = bData.slice(76, 92);
const text = bData.slice(92);
const key = pbkdf2Sync(password, salt, ITERATIONS, 32, 'sha512');
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return decipher.update(text, 'binary', 'utf8') + decipher.final('utf8');
} catch (e) {
throw new errors.UnableToReadKeystore();
}
}
save() {
const text = JSON.stringify(this.data);
// The encrypted text and the version are colon delimited to make
// it easy to visually read the version as we could have easily
// included it with the buffer
const keystore = [
VERSION,
Keystore.encrypt(text, this.password)
].join(':');
writeFileSync(this.path, keystore);
}
load() {
try {
const keystore = readFileSync(this.path);
const [, data] = keystore.toString().split(':');
this.data = JSON.parse(Keystore.decrypt(data, this.password));
} catch (e) {
if (e.code === 'ENOENT') {
return;
}
throw e;
}
}
exists() {
return existsSync(this.path);
}
keys() {
return Object.keys(this.data);
}
has(key) {
return this.keys().indexOf(key) > -1;
}
add(key, value) {
this.data[key] = value;
}
remove(key) {
delete this.data[key];
}
}

View file

@ -0,0 +1,100 @@
import expect from 'expect.js';
import sinon from 'sinon';
import { PassThrough } from 'stream';
import { confirm, question } from '../prompt';
describe('prompt', () => {
const sandbox = sinon.sandbox.create();
let input;
let output;
beforeEach(() => {
input = new PassThrough();
output = new PassThrough();
});
afterEach(() => {
sandbox.restore();
});
describe('confirm', () => {
it('prompts for question', async () => {
const onData = sandbox.stub(output, 'write');
confirm('my question', { output });
sinon.assert.calledOnce(onData);
const { args } = onData.getCall(0);
expect(args[0]).to.eql('my question [y/N] ');
});
it('prompts for question with default true', async () => {
const onData = sandbox.stub(output, 'write');
confirm('my question', { output, default: true });
sinon.assert.calledOnce(onData);
const { args } = onData.getCall(0);
expect(args[0]).to.eql('my question [Y/n] ');
});
it('defaults to false', async () => {
process.nextTick(() => input.write('\n'));
const answer = await confirm('my question', { output, input });
expect(answer).to.be(false);
});
it('accepts "y"', async () => {
process.nextTick(() => input.write('y\n'));
const answer = await confirm('my question', { output, input });
expect(answer).to.be(true);
});
it('accepts "Y"', async () => {
process.nextTick(() => input.write('Y\n'));
const answer = await confirm('my question', { output, input });
expect(answer).to.be(true);
});
it('accepts "yes"', async () => {
process.nextTick(() => input.write('yes\n'));
const answer = await confirm('my question', { output, input });
expect(answer).to.be(true);
});
it('is false when unknown', async () => {
process.nextTick(() => input.write('unknown\n'));
const answer = await confirm('my question', { output, input });
expect(answer).to.be(false);
});
});
describe('question', () => {
it('prompts for question', async () => {
const onData = sandbox.stub(output, 'write');
question('my question', { output });
sinon.assert.calledOnce(onData);
const { args } = onData.getCall(0);
expect(args[0]).to.eql('my question: ');
});
it('can be answered', async () => {
process.nextTick(() => input.write('my answer\n'));
const answer = await question('my question', { input, output });
expect(answer).to.be('my answer');
});
});
});

View file

@ -0,0 +1 @@
export { confirm, question } from './prompt';

View file

@ -0,0 +1,73 @@
import { createInterface } from 'readline';
/**
* @param {String} question
* @param {Object|null} options
* @property {Boolean} options.default
* @property {Stream} options.input - defaults to process.stdin
* @property {Stream} options.output - defaults to process.stdout
*/
export function confirm(question, options = {}) {
const rl = createInterface({
input: options.input || process.stdin,
output: options.output || process.stdout
});
return new Promise(resolve => {
const defautValue = options.default ? true : false;
const defaultPrompt = defautValue ? 'Y/n' : 'y/N';
rl.question(`${question} [${defaultPrompt}] `, input => {
let value = defautValue;
if (input != null && input !== '') {
value = /^y(es)?/i.test(input);
}
rl.close();
resolve(value);
});
});
}
/**
* @param {String} question
* @param {Object|null} options
* @property {Boolean} options.mask
* @property {Stream} options.input - defaults to process.stdin
* @property {Stream} options.output - defaults to process.stdout
*/
export function question(question, options = {}) {
const input = options.input || process.stdin;
const output = options.output || process.stdout;
const questionPrompt = `${question}: `;
const rl = createInterface({ input, output });
return new Promise(resolve => {
input.on('data', (char) => {
char = char + '';
switch (char) {
case '\n':
case '\r':
case '\u0004':
input.pause();
break;
default:
if (options.mask) {
output.cursorTo(questionPrompt.length);
output.write(Array(rl.line.length + 1).join(options.mask || '*'));
}
break;
}
});
rl.question(questionPrompt, value => {
resolve(value);
});
});
}