From 89cb777941af91ef24d3209b89ac3268b345c2a0 Mon Sep 17 00:00:00 2001 From: Ade Lee Date: Tue, 17 Oct 2017 11:15:51 -0400 Subject: [PATCH] Castellan based secret store This will provide a Castellan based secret store, which will allow secret stores which have a castellan backend to be used behind barbican. The initial example of this is the Vault backend. Unit tests have been added. In local tests, most of the functional tests do in fact pass with a local Vault backend, though this will need to be demonstrated with a later review which establishes a Vault based gate. Change-Id: Ib30fb79304014592bfc37938839d60a4c10c244d --- barbican/plugin/castellan_secret_store.py | 165 +++++++++++++ barbican/plugin/interface/secret_store.py | 14 ++ barbican/plugin/vault_secret_store.py | 82 +++++++ .../plugin/test_castellan_secret_store.py | 218 ++++++++++++++++++ lower-constraints.txt | 1 + requirements.txt | 1 + setup.cfg | 2 + test-requirements.txt | 1 + 8 files changed, 484 insertions(+) create mode 100644 barbican/plugin/castellan_secret_store.py create mode 100644 barbican/plugin/vault_secret_store.py create mode 100644 barbican/tests/plugin/test_castellan_secret_store.py diff --git a/barbican/plugin/castellan_secret_store.py b/barbican/plugin/castellan_secret_store.py new file mode 100644 index 000000000..b9b7000e2 --- /dev/null +++ b/barbican/plugin/castellan_secret_store.py @@ -0,0 +1,165 @@ +# Copyright (c) 2018 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc +import six + +from castellan.common.objects import opaque_data +from castellan import key_manager +from oslo_context import context +from oslo_log import log + +from barbican.plugin.interface import secret_store as ss + +LOG = log.getLogger(__name__) + + +@six.add_metaclass(abc.ABCMeta) +class CastellanSecretStore(ss.SecretStoreBase): + + KEY_ID = "key_id" + ALG = "alg" + BIT_LENGTH = "bit_length" + + def _set_params(self, conf): + self.key_manager = key_manager.API(conf) + self.context = context.get_current() + + @abc.abstractmethod + def get_conf(self, conf): + """Get plugin configuration + + This method is supposed to be implemented by the relevant + subclass. This method reads in the config for the plugin + in barbican.conf -- which should look like the way other + barbican plugins are configured, and convert them to the + proper oslo.config object to be passed to the keymanager + API. (keymanager.API(conf) + + @returns oslo.config object + """ + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def get_plugin_name(self): + """Get plugin name + + This method is implemented by the subclass. + Note that this name must be unique across the deployment. + """ + raise NotImplementedError # pragma: no cover + + def get_secret(self, secret_type, secret_metadata): + secret_ref = secret_metadata[CastellanSecretStore.KEY_ID] + try: + secret = self.key_manager.get( + self.context, + secret_ref) + return secret.get_encoded() + except Exception as e: + LOG.exception("Error retrieving secret {}: {}".format( + secret_ref, six.text_type(e))) + raise ss.SecretGeneralException(e) + + def store_secret(self, secret_dto): + if not self.store_secret_supports(secret_dto.key_spec): + raise ss.SecretAlgorithmNotSupportedException( + secret_dto.key_spec.alg) + + try: + secret_ref = self.key_manager.store( + self.context, + opaque_data.OpaqueData(secret_dto.secret) + ) + return {CastellanSecretStore.KEY_ID: secret_ref} + except Exception as e: + LOG.exception("Error storing secret: {}".format( + six.text_type(e))) + raise ss.SecretGeneralException(e) + + def delete_secret(self, secret_metadata): + secret_ref = secret_metadata[CastellanSecretStore.KEY_ID] + try: + self.key_manager.delete( + self.context, + secret_ref) + except KeyError: + LOG.warning("Attempting to delete a non-existent secret {}".format( + secret_ref)) + except Exception as e: + LOG.exception("Error deleting secret: {}".format( + six.text_type(e))) + raise ss.SecretGeneralException(e) + + def generate_symmetric_key(self, key_spec): + if not self.generate_supports(key_spec): + raise ss.SecretAlgorithmNotSupportedException( + key_spec.alg) + try: + secret_ref = self.key_manager.create_key( + self.context, + key_spec.alg, + key_spec.bit_length + ) + return {CastellanSecretStore.KEY_ID: secret_ref} + except Exception as e: + LOG.exception("Error generating symmetric key: {}".format( + six.text_type(e))) + raise ss.SecretGeneralException(e) + + def generate_asymmetric_key(self, key_spec): + if not self.generate_supports(key_spec): + raise ss.SecretAlgorithmNotSupportedException( + key_spec.alg) + + if key_spec.passphrase: + raise ss.GeneratePassphraseNotSupportedException() + + try: + private_ref, public_ref = self.key_manager.create_key_pair( + self.context, + key_spec.alg, + key_spec.bit_length + ) + + private_key_metadata = { + CastellanSecretStore.ALG: key_spec.alg, + CastellanSecretStore.BIT_LENGTH: key_spec.bit_length, + CastellanSecretStore.KEY_ID: private_ref + } + + public_key_metadata = { + CastellanSecretStore.ALG: key_spec.alg, + CastellanSecretStore.BIT_LENGTH: key_spec.bit_length, + CastellanSecretStore.KEY_ID: public_ref + } + + return ss.AsymmetricKeyMetadataDTO( + private_key_metadata, + public_key_metadata, + None + ) + except Exception as e: + LOG.exception("Error generating asymmetric key: {}".format( + six.text_type(e))) + raise ss.SecretGeneralException(e) + + @abc.abstractmethod + def store_secret_supports(self, key_spec): + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def generate_supports(self, key_spec): + raise NotImplementedError # pragma: no cover diff --git a/barbican/plugin/interface/secret_store.py b/barbican/plugin/interface/secret_store.py index a886d60b5..65f1eb077 100644 --- a/barbican/plugin/interface/secret_store.py +++ b/barbican/plugin/interface/secret_store.py @@ -227,6 +227,20 @@ class SecretAlgorithmNotSupportedException(exception.BarbicanHTTPException): self.algorithm = algorithm +class GeneratePassphraseNotSupportedException(exception.BarbicanHTTPException): + """Raised when generating keys encrypted by passphrase is not supported.""" + + client_message = ( + u._("Generating keys encrypted with passphrases is not supported") + ) + status_code = 400 + + def __init__(self): + super(GeneratePassphraseNotSupportedException, self).__init__( + self.client_message + ) + + class SecretStorePluginsNotConfigured(exception.BarbicanException): """Raised when there are no secret store plugins configured.""" def __init__(self): diff --git a/barbican/plugin/vault_secret_store.py b/barbican/plugin/vault_secret_store.py new file mode 100644 index 000000000..b1abd13c6 --- /dev/null +++ b/barbican/plugin/vault_secret_store.py @@ -0,0 +1,82 @@ +# Copyright (c) 2018 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from barbican.common import config +import barbican.plugin.castellan_secret_store as css +from castellan.i18n import _ +from castellan import options +from oslo_config import cfg +from oslo_log import log + +LOG = log.getLogger(__name__) + +DEFAULT_VAULT_URL = "http://127.0.0.1:8200" + +vault_opt_group = cfg.OptGroup(name='vault_plugin', title='Vault Plugin') +vault_opts = [ + cfg.StrOpt('root_token_id', + help='root token for vault'), + cfg.StrOpt('vault_url', + default=DEFAULT_VAULT_URL, + help='Use this endpoint to connect to Vault, for example: ' + '"%s"' % DEFAULT_VAULT_URL), + cfg.StrOpt('ssl_ca_crt_file', + help='Absolute path to ca cert file'), + cfg.BoolOpt('use_ssl', + default=False, + help=_('SSL Enabled/Disabled')), +] + +CONF = config.new_config() +CONF.register_group(vault_opt_group) +CONF.register_opts(vault_opts, group=vault_opt_group) +config.parse_args(CONF) + + +def list_opts(): + yield vault_opt_group, vault_opts # pragma: no cover + + +class VaultSecretStore(css.CastellanSecretStore): + + def __init__(self, conf=CONF): + """Constructor - create the vault secret store.""" + vault_conf = self.get_conf(conf) + self._set_params(vault_conf) + + def get_plugin_name(self): + return "VaultSecretStore" + + def get_conf(self, conf=CONF): + """Convert secret store conf into oslo conf + + Returns an oslo.config() object to pass to keymanager.API(conf) + """ + vault_conf = cfg.ConfigOpts() + options.set_defaults( + vault_conf, + backend='vault', + vault_root_token_id=conf.vault_plugin.root_token_id, + vault_url=conf.vault_plugin.vault_url, + vault_ssl_ca_crt_file=conf.vault_plugin.ssl_ca_crt_file, + vault_use_ssl=conf.vault_plugin.use_ssl + ) + return vault_conf + + def store_secret_supports(self, key_spec): + return True + + def generate_supports(self, key_spec): + return True diff --git a/barbican/tests/plugin/test_castellan_secret_store.py b/barbican/tests/plugin/test_castellan_secret_store.py new file mode 100644 index 000000000..600ba5389 --- /dev/null +++ b/barbican/tests/plugin/test_castellan_secret_store.py @@ -0,0 +1,218 @@ +# Copyright (c) 2018 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from castellan.common import exception +from castellan.common.objects import opaque_data +import mock + +import barbican.plugin.castellan_secret_store as css +import barbican.plugin.interface.secret_store as ss +import barbican.plugin.vault_secret_store as vss +from barbican.tests import utils + +key_ref1 = 'aff825be-6ede-4b1d-aeb0-aaec8e62aec6' +key_ref2 = '9c94c9c7-16ea-43e8-8ebe-0de282c0e6d5' +secret_passphrase = 'secret passphrase' + + +class WhenTestingVaultSecretStore(utils.BaseTestCase): + + def setUp(self): + super(WhenTestingVaultSecretStore, self).setUp() + self.key_manager_mock = mock.MagicMock(name="key manager mock") + self.key_manager_mock.create_key_pair.return_value = ( + key_ref1, key_ref2 + ) + self.key_manager_mock.create_key.return_value = key_ref1 + self.key_manager_mock.store.return_value = key_ref1 + + secret_object = opaque_data.OpaqueData(secret_passphrase) + self.key_manager_mock.get.return_value = secret_object + + self.cfg_mock = mock.MagicMock(name='config mock') + self.cfg_mock.vault_plugin = mock.MagicMock( + use_ssl=False, + root_token_id='12345' + ) + + self.plugin = vss.VaultSecretStore(self.cfg_mock) + self.plugin.key_manager = self.key_manager_mock + self.plugin_name = "VaultSecretStore" + + def test_generate_symmetric_key(self): + key_spec = ss.KeySpec(ss.KeyAlgorithm.AES, 128) + response = self.plugin.generate_symmetric_key(key_spec) + + self.plugin.key_manager.create_key.assert_called_once_with( + mock.ANY, + ss.KeyAlgorithm.AES, + 128 + ) + + expected_response = {css.CastellanSecretStore.KEY_ID: key_ref1} + self.assertEqual(response, expected_response) + + def test_generate_symmetric_key_raises_exception(self): + key_spec = ss.KeySpec(ss.KeyAlgorithm.AES, 128) + self.plugin.key_manager.create_key.side_effect = exception.Forbidden() + self.assertRaises( + ss.SecretGeneralException, + self.plugin.generate_symmetric_key, + key_spec + ) + + def test_generate_asymmetric_key(self): + key_spec = ss.KeySpec(ss.KeyAlgorithm.RSA, 2048) + response = self.plugin.generate_asymmetric_key(key_spec) + + self.plugin.key_manager.create_key_pair.assert_called_once_with( + mock.ANY, + ss.KeyAlgorithm.RSA, + 2048) + + self.assertIsInstance(response, ss.AsymmetricKeyMetadataDTO) + self.assertEqual( + response.public_key_meta[css.CastellanSecretStore.KEY_ID], + key_ref2 + ) + self.assertEqual( + response.private_key_meta[css.CastellanSecretStore.KEY_ID], + key_ref1 + ) + + def test_generate_asymmetric_throws_exception(self): + key_spec = ss.KeySpec(ss.KeyAlgorithm.RSA, 2048) + self.plugin.key_manager.create_key_pair.side_effect = ( + exception.Forbidden() + ) + self.assertRaises( + ss.SecretGeneralException, + self.plugin.generate_asymmetric_key, + key_spec + ) + + def test_generate_asymmetric_throws_passphrase_exception(self): + key_spec = ss.KeySpec( + alg=ss.KeyAlgorithm.RSA, + bit_length=2048, + passphrase="some passphrase" + ) + + self.assertRaises( + ss.GeneratePassphraseNotSupportedException, + self.plugin.generate_asymmetric_key, + key_spec + ) + + def test_store_secret(self): + payload = 'encrypt me!!' + key_spec = mock.MagicMock() + content_type = mock.MagicMock() + transport_key = None + secret_dto = ss.SecretDTO(ss.SecretType.SYMMETRIC, + payload, + key_spec, + content_type, + transport_key) + response = self.plugin.store_secret(secret_dto) + + data = opaque_data.OpaqueData(secret_dto.secret) + self.plugin.key_manager.store.assert_called_once_with( + mock.ANY, + data + ) + expected_response = {css.CastellanSecretStore.KEY_ID: key_ref1} + self.assertEqual(response, expected_response) + + def test_store_secret_raises_exception(self): + payload = 'encrypt me!!' + key_spec = mock.MagicMock() + content_type = mock.MagicMock() + transport_key = None + secret_dto = ss.SecretDTO(ss.SecretType.SYMMETRIC, + payload, + key_spec, + content_type, + transport_key) + + self.plugin.key_manager.store.side_effect = exception.Forbidden() + self.assertRaises( + ss.SecretGeneralException, + self.plugin.store_secret, + secret_dto + ) + + def test_get_secret(self): + secret_metadata = {css.CastellanSecretStore.KEY_ID: key_ref1} + response = self.plugin.get_secret( + ss.SecretType.SYMMETRIC, + secret_metadata + ) + + self.plugin.key_manager.get.assert_called_once_with( + mock.ANY, + key_ref1 + ) + + self.assertEqual(response, secret_passphrase) + + def test_get_secret_throws_exception(self): + secret_metadata = {css.CastellanSecretStore.KEY_ID: key_ref1} + self.plugin.key_manager.get.side_effect = exception.Forbidden() + self.assertRaises( + ss.SecretGeneralException, + self.plugin.get_secret, + ss.SecretType.SYMMETRIC, + secret_metadata + ) + + def test_delete_secret(self): + secret_metadata = {css.CastellanSecretStore.KEY_ID: key_ref1} + self.plugin.delete_secret(secret_metadata) + self.plugin.key_manager.delete.assert_called_once_with( + mock.ANY, + key_ref1 + ) + + def test_delete_secret_throws_exception(self): + secret_metadata = {css.CastellanSecretStore.KEY_ID: key_ref1} + self.plugin.key_manager.delete.side_effect = exception.Forbidden() + self.assertRaises( + ss.SecretGeneralException, + self.plugin.delete_secret, + secret_metadata + ) + + def test_delete_secret_throws_key_error(self): + secret_metadata = {css.CastellanSecretStore.KEY_ID: key_ref1} + self.plugin.key_manager.delete.side_effect = KeyError() + self.plugin.delete_secret(secret_metadata) + self.plugin.key_manager.delete.assert_called_once_with( + mock.ANY, + key_ref1 + ) + + def test_store_secret_supports(self): + self.assertTrue( + self.plugin.generate_supports(mock.ANY) + ) + + def test_generate_supports(self): + self.assertTrue( + self.plugin.generate_supports(mock.ANY) + ) + + def test_get_plugin_name(self): + self.assertEqual(self.plugin_name, self.plugin.get_plugin_name()) diff --git a/lower-constraints.txt b/lower-constraints.txt index bfdec423d..1df0eae5a 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -8,6 +8,7 @@ bandit==1.1.0 bcrypt==3.1.4 beautifulsoup4==4.6.0 cachetools==2.0.1 +castellan==0.17 certifi==2018.1.18 cffi==1.7.0 chardet==3.0.4 diff --git a/requirements.txt b/requirements.txt index 907a7ebdc..370403d6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,4 @@ six>=1.10.0 # MIT SQLAlchemy!=1.1.5,!=1.1.6,!=1.1.7,!=1.1.8,>=1.0.10 # MIT stevedore>=1.20.0 # Apache-2.0 WebOb>=1.7.1 # MIT +castellan >= 0.17 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 805cd41e9..b06bb5102 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,7 @@ barbican.secretstore.plugin = store_crypto = barbican.plugin.store_crypto:StoreCryptoAdapterPlugin dogtag_crypto = barbican.plugin.dogtag:DogtagKRAPlugin kmip_plugin = barbican.plugin.kmip_secret_store:KMIPSecretStore + vault_plugin = barbican.plugin.vault_secret_store:VaultSecretStore barbican.crypto.plugin = p11_crypto = barbican.plugin.crypto.p11_crypto:P11CryptoPlugin simple_crypto = barbican.plugin.crypto.simple_crypto:SimpleCryptoPlugin @@ -73,6 +74,7 @@ oslo.config.opts = barbican.plugin.dogtag = barbican.plugin.dogtag_config_opts:list_opts barbican.plugin.crypto.p11 = barbican.plugin.crypto.p11_crypto:list_opts barbican.plugin.secret_store.kmip = barbican.plugin.kmip_secret_store:list_opts + barbican.plugin.secret_store.vault = barbican.plugin.vault_secret_store:list_opts barbican.certificate.plugin = barbican.plugin.interface.certificate_manager:list_opts barbican.certificate.plugin.snakeoil = barbican.plugin.snakeoil_ca:list_opts oslo.config.opts.defaults = diff --git a/test-requirements.txt b/test-requirements.txt index b2ed3bcc7..be25b7f02 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -27,3 +27,4 @@ sphinx!=1.6.6,!=1.6.7,>=1.6.2 # BSD os-api-ref>=1.4.0 # Apache-2.0 reno>=2.5.0 # Apache-2.0 openstackdocstheme>=1.18.1 # Apache-2.0 +castellan >= 0.17 # Apache-2.0