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