[keystore] Add password support (#180414)

This adds support a password protected keystore. The UX should match
other stack products.

Closes https://github.com/elastic/kibana/issues/21756.

```
[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% bin/kibana-keystore create --password
A Kibana keystore already exists. Overwrite? [y/N] y
Enter new password for the kibana keystore (empty for no password): ********
Created Kibana keystore in /tmp/kibana-8.15.0-SNAPSHOT/config/kibana.keystore

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% bin/kibana-keystore add elasticsearch.username
Enter password for the kibana keystore: ********
Enter value for elasticsearch.username: *************

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% bin/kibana-keystore add elasticsearch.password
Enter password for the kibana keystore: ********
Enter value for elasticsearch.password: ********

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% bin/kibana
...
Enter password for the kibana keystore: ********
[2024-04-30T09:47:03.560-05:00][INFO ][root] Kibana is starting

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% bin/kibana-keystore has-passwd
Keystore is password-protected

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% ./bin/kibana-keystore show elasticsearch.username
Enter password for the kibana keystore: ********
kibana_system

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% ./bin/kibana-keystore remove elasticsearch.username
Enter password for the kibana keystore: ********

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% ./bin/kibana-keystore show elasticsearch.username
Enter password for the kibana keystore: ********
ERROR: Kibana keystore doesn't have requested key.

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% bin/kibana-keystore passwd
Enter password for the kibana keystore: ********
Enter new password for the kibana keystore (empty for no password):
[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% ./bin/kibana-keystore has-passwd
Error: Keystore is not password protected

[jon@mbpkbn1]/tmp/kibana-8.15.0-SNAPSHOT% ./bin/kibana
...
[2024-04-30T09:49:03.220-05:00][INFO ][root] Kibana is starting
```

## Password input

Environment variable usage is not consistent across stack products. I
implemented `KBN_KEYSTORE_PASSWORD_FILE` and `KBN_KEYSTORE_PASSWORD` to
be used to avoid prompts. @elastic/kibana-security do you have any
thoughts?


- `LOGSTASH_KEYSTORE_PASS` -
https://www.elastic.co/guide/en/logstash/current/keystore.html#keystore-password
- `KEYSTORE_PASSWORD` -
https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-keystore-bind-mount
- `ES_KEYSTORE_PASSPHRASE_FILE` -
https://www.elastic.co/guide/en/elasticsearch/reference/current/rpm.html#rpm-running-systemd
- Beats discussion, unresolved:
https://github.com/elastic/beats/issues/5737


## Release note
Adds password support to the Kibana keystore.
This commit is contained in:
Jon 2024-05-06 10:23:29 -05:00 committed by GitHub
parent 424411296f
commit 8b015ebedd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 442 additions and 108 deletions

1
.github/CODEOWNERS vendored
View file

@ -1204,6 +1204,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql
/src/setup_node_env/ @elastic/kibana-operations
/src/cli/keystore/ @elastic/kibana-operations
/src/cli/serve/ @elastic/kibana-operations
/src/cli_keystore/ @elastic/kibana-operations
/.github/workflows/ @elastic/kibana-operations
/vars/ @elastic/kibana-operations
/.bazelignore @elastic/kibana-operations

View file

@ -26,6 +26,8 @@ bin/kibana-keystore create
The file `kibana.keystore` will be created in the `config` directory defined by the
environment variable `KBN_PATH_CONF`.
To create a password protected keystore use the `--password` flag.
[float]
[[list-settings]]
=== List settings in the keystore
@ -92,3 +94,27 @@ To display the configured setting values, use the `show` command:
----------------------------------------------------------------
bin/kibana-keystore show setting.key
----------------------------------------------------------------
[float]
[[change-password]]
=== Change password
To change the password of the keystore, use the `passwd` command:
[source, sh]
----------------------------------------------------------------
bin/kibana-keystore passwd
----------------------------------------------------------------
[float]
[[has-password]]
=== Has password
To check if the keystore is password protected, use the `has-passwd` command.
An exit code of 0 will be returned if the keystore is password protected,
and the command will fail otherwise.
[source, sh]
----------------------------------------------------------------
bin/kibana-keystore has-passwd
----------------------------------------------------------------

View file

@ -2,7 +2,10 @@
== Start and stop {kib}
The method for starting and stopping {kib} varies depending on how you installed
it.
it. If a password protected keystore is used, the environment variable
`KBN_KEYSTORE_PASSPHRASE_FILE` can be used to point to a file containing the password,
the environment variable `KEYSTORE_PASSWORD` can be defined, or you will be prompted
to enter to enter the password on startup,
[float]
[[start-start-targz]]

View file

@ -8,6 +8,7 @@
import { writeFileSync, readFileSync, existsSync } from 'fs';
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
import { question } from './utils/prompt';
import * as errors from './errors';
const VERSION = 1;
@ -15,12 +16,16 @@ const ALGORITHM = 'aes-256-gcm';
const ITERATIONS = 10000;
export class Keystore {
static async initialize(path, password) {
const keystore = new Keystore(path, password);
await keystore.load();
return keystore;
}
constructor(path, password = '') {
this.path = path;
this.password = password;
this.reset();
this.load();
}
static errors = errors;
@ -71,11 +76,23 @@ export class Keystore {
writeFileSync(this.path, keystore);
}
load() {
async load() {
try {
if (this.hasPassword() && !this.password) {
if (process.env.KBN_KEYSTORE_PASSPHRASE_FILE) {
this.password = readFileSync(process.env.KBN_KEYSTORE_PASSPHRASE_FILE, {
encoding: 'utf8',
}).trim();
} else if (process.env.KEYSTORE_PASSWORD) {
this.password = process.env.KEYSTORE_PASSWORD;
} else {
this.password = await question('Enter password for the kibana keystore', {
mask: '*',
});
}
}
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') {
@ -109,4 +126,21 @@ export class Keystore {
remove(key) {
delete this.data[key];
}
hasPassword() {
try {
const keystore = readFileSync(this.path);
const [, data] = keystore.toString().split(':');
Keystore.decrypt(data);
} catch (e) {
if (e instanceof errors.UnableToReadKeystore) {
return true;
}
}
return false;
}
setPassword(password) {
this.password = password;
}
}

View file

@ -26,6 +26,14 @@ jest.mock('fs', () => ({
return JSON.stringify(mockProtectedKeystoreData);
}
if (path.includes('keystore_correct_password_file')) {
return 'changeme';
}
if (path.includes('keystore_incorrect_password_file')) {
return 'wrongpassword';
}
if (path.includes('data/test') || path.includes('data/nonexistent')) {
throw { code: 'ENOENT' };
}
@ -55,12 +63,12 @@ describe('Keystore', () => {
});
describe('save', () => {
it('thows permission denied', () => {
it('thows permission denied', async () => {
expect.assertions(1);
const path = '/inaccessible/test.keystore';
try {
const keystore = new Keystore(path);
const keystore = await Keystore.initialize(path);
keystore.save();
} catch (e) {
expect(e.code).toEqual('EACCES');
@ -84,23 +92,66 @@ describe('Keystore', () => {
});
describe('load', () => {
it('is called on initialization', () => {
const env = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...env };
});
afterAll(() => {
process.env = env;
});
it('is called on initialization', async () => {
const load = sandbox.spy(Keystore.prototype, 'load');
new Keystore('/data/protected.keystore', 'changeme');
await Keystore.initialize('/data/protected.keystore', 'changeme');
expect(load.calledOnce).toBe(true);
});
it('can load a password protected keystore', () => {
const keystore = new Keystore('/data/protected.keystore', 'changeme');
it('can load a password protected keystore', async () => {
const keystore = await Keystore.initialize('/data/protected.keystore', 'changeme');
expect(keystore.data).toEqual({ 'a1.b2.c3': 'foo', a2: 'bar' });
});
it('throws unable to read keystore', () => {
it('can load a valid password protected keystore from env KEYSTORE_PASSWORD', async () => {
process.env.KEYSTORE_PASSWORD = 'changeme';
const keystore = await Keystore.initialize('/data/protected.keystore');
expect(keystore.data).toEqual({ 'a1.b2.c3': 'foo', a2: 'bar' });
});
it('can not load a password protected keystore from env KEYSTORE_PASSWORD with the wrong password', async () => {
process.env.KEYSTORE_PASSWORD = 'wrongpassword';
expect.assertions(1);
try {
new Keystore('/data/protected.keystore', 'wrongpassword');
await Keystore.initialize('/data/protected.keystore');
} catch (e) {
expect(e).toBeInstanceOf(Keystore.errors.UnableToReadKeystore);
}
});
it('can load a password protected keystore from env KBN_KEYSTORE_PASSPHRASE_FILE', async () => {
process.env.KBN_KEYSTORE_PASSPHRASE_FILE = 'keystore_correct_password_file';
const keystore = await Keystore.initialize('/data/protected.keystore');
expect(keystore.data).toEqual({ 'a1.b2.c3': 'foo', a2: 'bar' });
});
it('can not load a password protected keystore from env KBN_KEYSTORE_PASSPHRASE_FILE with the wrong password', async () => {
process.env.KBN_KEYSTORE_PASSPHRASE_FILE = 'keystore_incorrect_password_file';
expect.assertions(1);
try {
await Keystore.initialize('/data/protected.keystore');
} catch (e) {
expect(e).toBeInstanceOf(Keystore.errors.UnableToReadKeystore);
}
});
it('throws unable to read keystore', async () => {
expect.assertions(1);
try {
await Keystore.initialize('/data/protected.keystore', 'wrongpassword');
} catch (e) {
expect(e).toBeInstanceOf(Keystore.errors.UnableToReadKeystore);
}
@ -112,16 +163,16 @@ describe('Keystore', () => {
});
describe('reset', () => {
it('clears the data', () => {
const keystore = new Keystore('/data/protected.keystore', 'changeme');
it('clears the data', async () => {
const keystore = await Keystore.initialize('/data/protected.keystore', 'changeme');
keystore.reset();
expect(keystore.data).toEqual({});
});
});
describe('keys', () => {
it('lists object keys', () => {
const keystore = new Keystore('/data/unprotected.keystore');
it('lists object keys', async () => {
const keystore = await Keystore.initialize('/data/unprotected.keystore');
const keys = keystore.keys();
expect(keys).toEqual(['a1.b2.c3', 'a2']);
@ -129,22 +180,22 @@ describe('Keystore', () => {
});
describe('has', () => {
it('returns true if key exists', () => {
const keystore = new Keystore('/data/unprotected.keystore');
it('returns true if key exists', async () => {
const keystore = await Keystore.initialize('/data/unprotected.keystore');
expect(keystore.has('a2')).toBe(true);
});
it('returns false if key does not exist', () => {
const keystore = new Keystore('/data/unprotected.keystore');
it('returns false if key does not exist', async () => {
const keystore = await Keystore.initialize('/data/unprotected.keystore');
expect(keystore.has('invalid')).toBe(false);
});
});
describe('add', () => {
it('adds a key/value pair', () => {
const keystore = new Keystore('/data/unprotected.keystore');
it('adds a key/value pair', async () => {
const keystore = await Keystore.initialize('/data/unprotected.keystore');
keystore.add('a3', 'baz');
keystore.add('a4', [1, 'a', 2, 'b']);
@ -158,8 +209,8 @@ describe('Keystore', () => {
});
describe('remove', () => {
it('removes a key/value pair', () => {
const keystore = new Keystore('/data/unprotected.keystore');
it('removes a key/value pair', async () => {
const keystore = await Keystore.initialize('/data/unprotected.keystore');
keystore.remove('a1.b2.c3');
expect(keystore.data).toEqual({

View file

@ -11,10 +11,8 @@ import { set } from '@kbn/safer-lodash-set';
import { Keystore } from '.';
import { getKeystore } from './get_keystore';
export function readKeystore(keystorePath = getKeystore()) {
const keystore = new Keystore(keystorePath);
keystore.load();
export async function readKeystore(keystorePath = getKeystore()) {
const keystore = await Keystore.initialize(keystorePath);
const keys = Object.keys(keystore.data);
const data = {};

View file

@ -14,14 +14,18 @@ import { Keystore } from '.';
describe('cli/serve/read_keystore', () => {
beforeEach(() => {
Keystore.initialize.mockResolvedValue(Promise.resolve(new Keystore()));
});
afterEach(() => {
jest.resetAllMocks();
});
it('returns structured keystore data', () => {
it('returns structured keystore data', async () => {
const keystoreData = { 'elasticsearch.password': 'changeme' };
Keystore.prototype.data = keystoreData;
const data = readKeystore();
const data = await readKeystore();
expect(data).toEqual({
elasticsearch: {
password: 'changeme',
@ -29,17 +33,17 @@ describe('cli/serve/read_keystore', () => {
});
});
it('uses data path if provided', () => {
it('uses data path if provided', async () => {
const keystorePath = path.join('/foo/', 'kibana.keystore');
readKeystore(keystorePath);
expect(Keystore.mock.calls[0][0]).toContain(keystorePath);
await readKeystore(keystorePath);
expect(Keystore.initialize.mock.calls[0][0]).toContain(keystorePath);
});
it('uses the getKeystore path if not', () => {
readKeystore();
it('uses the getKeystore path if not', async () => {
await readKeystore();
// we test exact path scenarios in get_keystore.test.js - we use both
// deprecated and new to cover any older local environments
expect(Keystore.mock.calls[0][0]).toMatch(/data|config/);
expect(Keystore.initialize.mock.calls[0][0]).toMatch(/data|config/);
});
});

View file

@ -96,7 +96,7 @@ function pathCollector() {
const configPathCollector = pathCollector();
const pluginPathCollector = pathCollector();
export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
export function applyConfigOverrides(rawConfig, opts, extraCliOptions, keystoreConfig) {
const set = _.partial(lodashSet, rawConfig);
const get = _.partial(_.get, rawConfig);
const has = _.partial(_.has, rawConfig);
@ -209,7 +209,7 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
set('plugins.paths', _.compact([].concat(get('plugins.paths'), opts.pluginPath)));
_.mergeWith(rawConfig, extraCliOptions, mergeAndReplaceArrays);
_.merge(rawConfig, readKeystore());
_.merge(rawConfig, keystoreConfig);
return rawConfig;
}
@ -324,11 +324,12 @@ export default function (program) {
// Kibana server process, and will be using core's bootstrap script
// to effectively start Kibana.
const bootstrapScript = getBootstrapScript(cliArgs.dev);
const keystoreConfig = await readKeystore();
await bootstrapScript({
configs,
cliArgs,
applyConfigOverrides: (rawConfig) => applyConfigOverrides(rawConfig, opts, unknownOptions),
applyConfigOverrides: (rawConfig) =>
applyConfigOverrides(rawConfig, opts, unknownOptions, keystoreConfig),
});
});
}

View file

@ -10,7 +10,7 @@ import { applyConfigOverrides } from './serve';
describe('applyConfigOverrides', () => {
it('merges empty objects to an empty config', () => {
const output = applyConfigOverrides({}, {}, {});
const output = applyConfigOverrides({}, {}, {}, {});
const defaultEmptyConfig = {
plugins: {
paths: [],
@ -33,7 +33,8 @@ describe('applyConfigOverrides', () => {
tomato: {
weight: 100,
},
}
},
{}
);
expect(output).toEqual({
@ -63,7 +64,8 @@ describe('applyConfigOverrides', () => {
weight: 100,
arr: [4, 5],
},
}
},
{}
);
expect(output).toEqual({

View file

@ -8,7 +8,7 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import { confirm, question } from '../cli_keystore/utils';
import { confirm, question } from '../cli/keystore/utils';
import { getConfigDirectory } from '@kbn/utils';
import { safeDump } from 'js-yaml';

View file

@ -10,7 +10,7 @@ import { EncryptionConfig } from './encryption_config';
import { generate } from './generate';
import { Logger } from '../cli/logger';
import * as prompt from '../cli_keystore/utils/prompt';
import * as prompt from '../cli/keystore/utils/prompt';
import fs from 'fs';
import crypto from 'crypto';

View file

@ -7,7 +7,7 @@
*/
import { Logger } from '../cli/logger';
import { confirm, question } from './utils';
import { confirm, question } from '../cli/keystore/utils';
// import from path since add.test.js mocks 'fs' required for @kbn/utils
import { createPromiseFromStreams, createConcatStream } from '@kbn/utils/src/streams';
@ -24,6 +24,8 @@ export async function add(keystore, key, options = {}) {
const logger = new Logger(options);
let value;
await keystore.load();
if (!keystore.exists()) {
return logger.error("ERROR: Kibana keystore not found. Use 'create' command to create one.");
}

View file

@ -31,7 +31,7 @@ import { PassThrough } from 'stream';
import { Keystore } from '../cli/keystore';
import { add } from './add';
import { Logger } from '../cli/logger';
import * as prompt from './utils/prompt';
import * as prompt from '../cli/keystore/utils/prompt';
describe('Kibana keystore', () => {
describe('add', () => {

View file

@ -18,39 +18,47 @@ import { listCli } from './list';
import { addCli } from './add';
import { removeCli } from './remove';
import { showCli } from './show';
import { passwdCli } from './passwd';
import { hasPasswdCli } from './has_passwd';
const argv = process.argv.slice();
const program = new Command('bin/kibana-keystore');
program
.version(pkg.version)
.description('A tool for managing settings stored in the Kibana keystore');
async function initialize() {
const program = new Command('bin/kibana-keystore');
program
.version(pkg.version)
.description('A tool for managing settings stored in the Kibana keystore');
const keystore = new Keystore(getKeystore());
const keystore = new Keystore(getKeystore());
createCli(program, keystore);
listCli(program, keystore);
addCli(program, keystore);
removeCli(program, keystore);
showCli(program, keystore);
createCli(program, keystore);
listCli(program, keystore);
addCli(program, keystore);
removeCli(program, keystore);
showCli(program, keystore);
passwdCli(program, keystore);
hasPasswdCli(program, keystore);
program
.command('help <command>')
.description('get the help for a specific command')
.action(function (cmdName) {
const cmd = _.find(program.commands, { _name: cmdName });
if (!cmd) return program.error(`unknown command ${cmdName}`);
cmd.help();
program
.command('help <command>')
.description('get the help for a specific command')
.action(function (cmdName) {
const cmd = _.find(program.commands, { _name: cmdName });
if (!cmd) return program.error(`unknown command ${cmdName}`);
cmd.help();
});
program.command('*', null, { noHelp: true }).action(function (cmd) {
program.error(`unknown command ${cmd}`);
});
program.command('*', null, { noHelp: true }).action(function (cmd) {
program.error(`unknown command ${cmd}`);
});
// check for no command name
const subCommand = argv[2] && !String(argv[2][0]).match(/^-|^\.|\//);
if (!subCommand) {
program.defaultHelp();
}
// check for no command name
const subCommand = argv[2] && !String(argv[2][0]).match(/^-|^\.|\//);
if (!subCommand) {
program.defaultHelp();
program.parse(process.argv);
}
program.parse(process.argv);
initialize().catch((e) => console.error(e));

View file

@ -7,9 +7,9 @@
*/
import { Logger } from '../cli/logger';
import { confirm } from './utils';
import { confirm, question } from '../cli/keystore/utils';
export async function create(keystore, options) {
export async function create(keystore, options = {}) {
const logger = new Logger(options);
if (keystore.exists()) {
@ -21,6 +21,17 @@ export async function create(keystore, options) {
}
keystore.reset();
if (options.password) {
const password = await question(
'Enter new password for the kibana keystore (empty for no password)',
{
mask: '*',
}
);
if (password) keystore.setPassword(password);
}
keystore.save();
logger.log(`Created Kibana keystore in ${keystore.path}`);
@ -30,6 +41,7 @@ export function createCli(program, keystore) {
program
.command('create')
.description('Creates a new Kibana keystore')
.option('-s, --silent', 'prevent all logging')
.option('-p, --password', 'Prompt for password to encrypt the keystore')
.option('-s, --silent', 'Show minimal output')
.action(create.bind(null, keystore));
}

View file

@ -30,7 +30,7 @@ import sinon from 'sinon';
import { Keystore } from '../cli/keystore';
import { create } from './create';
import { Logger } from '../cli/logger';
import * as prompt from './utils/prompt';
import * as prompt from '../cli/keystore/utils/prompt';
describe('Kibana keystore', () => {
describe('create', () => {
@ -46,7 +46,7 @@ describe('Kibana keystore', () => {
});
it('creates keystore file', async () => {
const keystore = new Keystore('/data/foo.keystore');
const keystore = await Keystore.initialize('/data/foo.keystore');
sandbox.stub(keystore, 'save');
await create(keystore);
@ -56,7 +56,7 @@ describe('Kibana keystore', () => {
it('logs successful keystore creating', async () => {
const path = '/data/foo.keystore';
const keystore = new Keystore(path);
const keystore = await Keystore.initialize(path);
await create(keystore);
@ -67,7 +67,7 @@ describe('Kibana keystore', () => {
it('prompts for overwrite', async () => {
sandbox.stub(prompt, 'confirm').returns(Promise.resolve(true));
const keystore = new Keystore('/data/test.keystore');
const keystore = await Keystore.initialize('/data/test.keystore');
await create(keystore);
sinon.assert.calledOnce(prompt.confirm);
@ -79,7 +79,7 @@ describe('Kibana keystore', () => {
it('aborts if overwrite is denied', async () => {
sandbox.stub(prompt, 'confirm').returns(Promise.resolve(false));
const keystore = new Keystore('/data/test.keystore');
const keystore = await Keystore.initialize('/data/test.keystore');
sandbox.stub(keystore, 'save');
await create(keystore);

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Logger } from '../cli/logger';
export function hasPasswd(keystore, options = {}) {
const logger = new Logger();
if (!keystore.exists()) {
if (!options.silent) logger.error('Error: Keystore not found');
return process.exit(1);
}
if (!keystore.hasPassword()) {
if (!options.silent) logger.error('Error: Keystore is not password protected');
return process.exit(1);
}
if (!options.silent) {
logger.log('Keystore is password-protected');
return process.exit(0);
}
}
export function hasPasswdCli(program, keystore) {
program
.command('has-passwd')
.description(
'Succeeds if the keystore exists and is password-protected, fails with exit code 1 otherwise'
)
.option('-s, --silent', 'prevent all logging')
.action(hasPasswd.bind(null, keystore));
}

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
const mockKeystoreWithoutPassword =
'1:20nsJf6P1Koi1x2kwrOhc4la7bqisOqJFlb5XpI95Qc/4sJjCHxoRzO1iGiBuoAtqolCHxRs976t59uFXQXtTv9zY5PoUvGyoPOxbA4q/H7n+EygneCbSc18MGHXA5K0NZm8RBhjWaKphe4=';
const mockKeystoreWithPassword =
'1:j/zZA0L6cPonF6zacVTOT0qwZeXgPJOZrLHhFYg+CzchCIcjjhH/70JyHj7gPCEa/ZrBm8gCAKbcXSo8eQsHP25Qf922f/tXI9m6IiXPf6G/v/KiO0rOSjobDNFYWCxCD7aIJmYnuoPMhqc=';
jest.mock('fs', () => ({
readFileSync: jest.fn().mockImplementation((path) => {
if (path.includes('with_password.keystore')) {
return JSON.stringify(mockKeystoreWithPassword);
}
if (path.includes('without_password.keystore')) {
return JSON.stringify(mockKeystoreWithoutPassword);
}
throw { code: 'ENOENT' };
}),
existsSync: jest.fn().mockImplementation(() => true),
writeFileSync: jest.fn(),
}));
import sinon from 'sinon';
import { Keystore } from '../cli/keystore';
import { hasPasswd } from './has_passwd';
import { Logger } from '../cli/logger';
describe('Kibana keystore', () => {
describe('has_passwd', () => {
const sandbox = sinon.createSandbox();
beforeEach(() => {
sandbox.stub(Logger.prototype, 'log');
sandbox.stub(Logger.prototype, 'error');
});
afterEach(() => {
sandbox.restore();
});
it('exits 0 if password protected', async () => {
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const keystore = new Keystore('with_password.keystore');
hasPasswd(keystore);
expect(mockExit).toHaveBeenCalledWith(0);
sinon.assert.calledWith(Logger.prototype.log, 'Keystore is password-protected');
});
it('exits 1 if not password protected', async () => {
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const keystore = new Keystore('without_password.keystore');
hasPasswd(keystore);
expect(mockExit).toHaveBeenCalledWith(1);
sinon.assert.calledWith(Logger.prototype.error, 'Error: Keystore is not password protected');
});
});
afterAll(() => {
jest.restoreAllMocks();
});
});

View file

@ -8,8 +8,9 @@
import { Logger } from '../cli/logger';
export function list(keystore, options = {}) {
export async function list(keystore, options = {}) {
const logger = new Logger(options);
await keystore.load();
if (!keystore.exists()) {
return logger.error("ERROR: Kibana keystore not found. Use 'create' command to create one.");

View file

@ -42,17 +42,17 @@ describe('Kibana keystore', () => {
sandbox.restore();
});
it('outputs keys', () => {
it('outputs keys', async () => {
const keystore = new Keystore('/data/test.keystore');
list(keystore);
await list(keystore);
sinon.assert.calledOnce(Logger.prototype.log);
sinon.assert.calledWith(Logger.prototype.log, 'a1.b2.c3\na2');
});
it('handles a nonexistent keystore', () => {
it('handles a nonexistent keystore', async () => {
const keystore = new Keystore('/data/nonexistent.keystore');
list(keystore);
await list(keystore);
sinon.assert.calledOnce(Logger.prototype.error);
sinon.assert.calledWith(

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Logger } from '../cli/logger';
import { question } from '../cli/keystore/utils';
export async function passwd(keystore, options = {}) {
const logger = new Logger(options);
await keystore.load();
if (!keystore.exists()) {
return logger.error("ERROR: Kibana keystore not found. Use 'create' command to create one.");
}
const password =
(await question('Enter new password for the kibana keystore (empty for no password)', {
mask: '*',
})) || '';
keystore.setPassword(password);
keystore.save();
}
export function passwdCli(program, keystore) {
program
.command('passwd')
.description('Changes the password of a keystore')
.option('-s, --silent', 'prevent all logging')
.action(passwd.bind(null, keystore));
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
const mockKeystoreWithOldPassword =
'1:9OsRzJI+gyDEH1ZjAHuKZFfYH7nEguzFRJwxWgj5WTJm5w+mzwKUzIdy65/lBW+XxY4wa1qYf0RSGJmfJKPz/er7pt58RJ8OgpicM2nCOMrqjPuovQr0QoMPbx736YlHEEIsuAaGAGItW7rlAQ==';
jest.mock('fs', () => ({
readFileSync: jest.fn().mockImplementation(() => JSON.stringify(mockKeystoreWithOldPassword)),
existsSync: jest.fn().mockImplementation(() => true),
writeFileSync: jest.fn(),
}));
import * as prompt from '../cli/keystore/utils/prompt';
import { Keystore } from '../cli/keystore';
import { passwd } from './passwd';
import fs from 'fs';
describe('Kibana keystore', () => {
describe('has_passwd', () => {
it('changes the password', async () => {
const keystore = new Keystore('keystore', 'old_password');
jest.spyOn(prompt, 'question').mockResolvedValue('new_password');
await passwd(keystore);
const newKeystoreData = fs.writeFileSync.mock.calls[0][1];
jest.spyOn(fs, 'readFileSync').mockReturnValue(newKeystoreData);
const newKeystore = await Keystore.initialize('keystore', 'new_password');
expect(newKeystore.data).toEqual({ hello: 'world' });
});
});
afterAll(() => {
jest.restoreAllMocks();
});
});

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
export function remove(keystore, key) {
export async function remove(keystore, key) {
await keystore.load();
keystore.remove(key);
keystore.save();
}

View file

@ -30,19 +30,19 @@ describe('Kibana keystore', () => {
sandbox.restore();
});
it('removes key', () => {
it('removes key', async () => {
const keystore = new Keystore('/data/test.keystore');
remove(keystore, 'a2');
await remove(keystore, 'a2');
expect(keystore.data).toEqual({ 'a1.b2.c3': 'foo' });
});
it('persists the keystore', () => {
it('persists the keystore', async () => {
const keystore = new Keystore('/data/test.keystore');
sandbox.stub(keystore, 'save');
remove(keystore, 'a2');
await remove(keystore, 'a2');
sinon.assert.calledOnce(keystore.save);
});

View file

@ -41,42 +41,46 @@ import { Keystore } from '../cli/keystore';
import { show } from './show';
describe('Kibana keystore: show', () => {
const keystore = new Keystore('mock-path', '');
let keystore: Keystore;
it('reads stored strings', () => {
const exitCode = show(keystore, 'foo', {});
beforeAll(async () => {
keystore = new Keystore('mock-path', '');
});
it('reads stored strings', async () => {
const exitCode = await show(keystore, 'foo', {});
expect(exitCode).toBe(0);
expect(mockLogFn).toHaveBeenCalledWith('turbo2000');
expect(mockErrFn).not.toHaveBeenCalled();
});
it('reads stored numbers', () => {
const exitCode = show(keystore, 'num', {});
it('reads stored numbers', async () => {
const exitCode = await show(keystore, 'num', {});
expect(exitCode).toBe(0);
expect(mockLogFn).toHaveBeenCalledWith('12345');
expect(mockErrFn).not.toHaveBeenCalled();
});
it('reads stored objecs', () => {
const exitCode = show(keystore, 'bar', {});
it('reads stored objecs', async () => {
const exitCode = await show(keystore, 'bar', {});
expect(exitCode).toBe(0);
expect(mockLogFn).toHaveBeenCalledWith(JSON.stringify({ sub: 0 }));
expect(mockErrFn).not.toHaveBeenCalled();
});
it('outputs to a file when the arg is passed', () => {
const exitCode = show(keystore, 'foo', { output: 'non-existent-file.txt' });
it('outputs to a file when the arg is passed', async () => {
const exitCode = await show(keystore, 'foo', { output: 'non-existent-file.txt' });
expect(exitCode).toBe(0);
expect(mockLogFn).toHaveBeenCalledWith('Writing output to file: non-existent-file.txt');
expect(mockErrFn).not.toHaveBeenCalled();
});
it('logs and terminates with an error when output file exists', () => {
const exitCode = show(keystore, 'foo', { output: 'existing-file.txt' });
it('logs and terminates with an error when output file exists', async () => {
const exitCode = await show(keystore, 'foo', { output: 'existing-file.txt' });
expect(exitCode).toBe(-1);
expect(mockErrFn).toHaveBeenCalledWith(
@ -85,8 +89,8 @@ describe('Kibana keystore: show', () => {
expect(mockLogFn).not.toHaveBeenCalled();
});
it("logs and terminates with an error when the store doesn't have the key", () => {
const exitCode = show(keystore, 'no-key');
it("logs and terminates with an error when the store doesn't have the key", async () => {
const exitCode = await show(keystore, 'no-key');
expect(exitCode).toBe(-1);
expect(mockErrFn).toHaveBeenCalledWith("ERROR: Kibana keystore doesn't have requested key.");

View file

@ -16,10 +16,16 @@ interface ShowOptions {
output?: string;
}
export function show(keystore: Keystore, key: string, options: ShowOptions = {}): number | void {
export async function show(
keystore: Keystore,
key: string,
options: ShowOptions = {}
): Promise<number | void> {
const { silent, output } = options;
const logger = new Logger({ silent });
await keystore.load();
if (!keystore.exists()) {
logger.error("ERROR: Kibana keystore not found. Use 'create' command to create one.");
return -1;
@ -56,7 +62,7 @@ export function showCli(program: any, keystore: Keystore) {
)
.option('-s, --silent', 'prevent all logging')
.option('-o, --output <file>', 'output value to a file')
.action((key: string, options: ShowOptions) => {
process.exitCode = show(keystore, key, options) || 0;
.action(async (key: string, options: ShowOptions) => {
process.exitCode = (await show(keystore, key, options)) || 0;
});
}

View file

@ -5,7 +5,6 @@
},
"include": [
"keystore/**/*",
"utils/**/*",
"*.js",
"*.ts",
],