Skip to content

Commit

Permalink
[keystore] Add password support (#180414)
Browse files Browse the repository at this point in the history
This adds support a password protected keystore. The UX should match
other stack products.

Closes #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:
elastic/beats#5737


## Release note
Adds password support to the Kibana keystore.
  • Loading branch information
jbudz authored May 6, 2024
1 parent 4244112 commit 8b015eb
Show file tree
Hide file tree
Showing 30 changed files with 446 additions and 112 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/setup/secure-settings.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
----------------------------------------------------------------
5 changes: 4 additions & 1 deletion docs/setup/start-stop.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
42 changes: 38 additions & 4 deletions src/cli/keystore/keystore.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@

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;
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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
}
}
91 changes: 71 additions & 20 deletions src/cli/keystore/keystore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
}
Expand Down Expand Up @@ -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');
Expand All @@ -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('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('throws unable to read keystore', () => {
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 {
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 {
new Keystore('/data/protected.keystore', 'wrongpassword');
await Keystore.initialize('/data/protected.keystore', 'wrongpassword');
} catch (e) {
expect(e).toBeInstanceOf(Keystore.errors.UnableToReadKeystore);
}
Expand All @@ -112,39 +163,39 @@ 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']);
});
});

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']);

Expand All @@ -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({
Expand Down
6 changes: 2 additions & 4 deletions src/cli/keystore/read_keystore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down
20 changes: 12 additions & 8 deletions src/cli/keystore/read_keystore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,36 @@ 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',
},
});
});

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/);
});
});
File renamed without changes.
File renamed without changes.
File renamed without changes.
9 changes: 5 additions & 4 deletions src/cli/serve/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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),
});
});
}
Expand Down
Loading

0 comments on commit 8b015eb

Please sign in to comment.