castellan/castellan/key_manager/vault_key_manager.py

408 lines
15 KiB
Python

# 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.
"""
Key manager implementation for Vault
"""
import binascii
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.hazmat.primitives.serialization import NoEncryption
from cryptography.hazmat.primitives.serialization import PrivateFormat
from cryptography.hazmat.primitives.serialization import PublicFormat
import os
import time
import uuid
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import timeutils
import requests
from castellan.common import exception
from castellan.common.objects import private_key as pri_key
from castellan.common.objects import public_key as pub_key
from castellan.common.objects import symmetric_key as sym_key
from castellan.i18n import _
from castellan.key_manager import key_manager
_DEFAULT_VAULT_URL = "http://127.0.0.1:8200"
_DEFAULT_MOUNTPOINT = "secret"
_DEFAULT_VERSION = 2
_vault_opts = [
cfg.StrOpt('root_token_id',
help='root token for vault'),
cfg.StrOpt('approle_role_id',
help='AppRole role_id for authentication with vault'),
cfg.StrOpt('approle_secret_id',
help='AppRole secret_id for authentication with vault'),
cfg.StrOpt('kv_mountpoint',
default=_DEFAULT_MOUNTPOINT,
help='Mountpoint of KV store in Vault to use, for example: '
'{}'.format(_DEFAULT_MOUNTPOINT)),
cfg.StrOpt('kv_path',
help='Path relative to root of KV store in Vault to use.'
),
cfg.IntOpt('kv_version',
default=_DEFAULT_VERSION,
help='Version of KV store in Vault to use, for example: '
'{}'.format(_DEFAULT_VERSION)),
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')),
cfg.StrOpt("namespace",
help=_("Vault Namespace to use for all requests to Vault. "
"Vault Namespaces feature is available only in "
"Vault Enterprise")),
cfg.FloatOpt('timeout',
default=60,
help=_('Timeout (in seconds) in each request to Vault')),
]
_VAULT_OPT_GROUP = 'vault'
_EXCEPTIONS_BY_CODE = [
requests.codes['internal_server_error'],
requests.codes['service_unavailable'],
requests.codes['request_timeout'],
requests.codes['gateway_timeout'],
requests.codes['precondition_failed'],
]
LOG = logging.getLogger(__name__)
class VaultKeyManager(key_manager.KeyManager):
"""Key Manager Interface that wraps the Vault REST API."""
def __init__(self, configuration):
self._conf = configuration
self._conf.register_opts(_vault_opts, group=_VAULT_OPT_GROUP)
self._session = requests.Session()
self._root_token_id = self._conf.vault.root_token_id
self._approle_role_id = self._conf.vault.approle_role_id
self._approle_secret_id = self._conf.vault.approle_secret_id
self._cached_approle_token_id = None
self._approle_token_ttl = None
self._approle_token_issue = None
self._kv_mountpoint = self._conf.vault.kv_mountpoint
self._kv_path = self._conf.vault.kv_path
self._kv_version = self._conf.vault.kv_version
self._vault_url = self._conf.vault.vault_url
self._namespace = self._conf.vault.namespace
self._timeout = self._conf.vault.timeout
if self._vault_url.startswith("https://"):
self._verify_server = self._conf.vault.ssl_ca_crt_file or True
else:
self._verify_server = False
def _get_url(self):
if not self._vault_url.endswith('/'):
self._vault_url += '/'
return self._vault_url
def _get_resource_url(self, key_id=None):
return '{}v1/{}/{}{}{}'.format(
self._get_url(),
self._kv_mountpoint,
'' if self._kv_version == 1 else
'data/' if key_id else
'metadata/', # no key_id is for listing and 'data/' doesn't works
(self._kv_path + '/') if self._kv_path else '',
key_id if key_id else '?list=true')
@property
def _approle_token_id(self):
if (all((self._approle_token_issue, self._approle_token_ttl)) and
timeutils.is_older_than(self._approle_token_issue,
self._approle_token_ttl)):
self._cached_approle_token_id = None
return self._cached_approle_token_id
def _set_namespace(self, headers):
if self._namespace:
headers["X-Vault-Namespace"] = self._namespace
return headers
def _build_auth_headers(self):
if self._root_token_id:
return self._set_namespace(
{'X-Vault-Token': self._root_token_id})
if self._approle_token_id:
return self._set_namespace(
{'X-Vault-Token': self._approle_token_id})
if self._approle_role_id:
params = {
'role_id': self._approle_role_id
}
if self._approle_secret_id:
params['secret_id'] = self._approle_secret_id
approle_login_url = '{}v1/auth/approle/login'.format(
self._get_url()
)
token_issue_utc = timeutils.utcnow()
headers = self._set_namespace({})
try:
resp = self._session.post(url=approle_login_url,
json=params,
headers=headers,
verify=self._verify_server,
timeout=self._timeout)
except requests.exceptions.Timeout as ex:
raise exception.KeyManagerError(str(ex))
except requests.exceptions.ConnectionError as ex:
raise exception.KeyManagerError(str(ex))
except Exception as ex:
raise exception.KeyManagerError(str(ex))
if resp.status_code in _EXCEPTIONS_BY_CODE:
raise exception.KeyManagerError(resp.reason)
if resp.status_code == requests.codes['forbidden']:
raise exception.Forbidden()
resp_data = resp.json()
if resp.status_code == requests.codes['bad_request']:
raise exception.KeyManagerError(', '.join(resp_data['errors']))
self._cached_approle_token_id = resp_data['auth']['client_token']
self._approle_token_issue = token_issue_utc
self._approle_token_ttl = resp_data['auth']['lease_duration']
return self._set_namespace(
{'X-Vault-Token': self._approle_token_id})
return {}
def _do_http_request(self, method, resource, json=None):
headers = self._build_auth_headers()
try:
resp = method(resource, headers=headers, json=json,
verify=self._verfy_server, timeout=self._timeout)
except requests.exceptions.Timeout as ex:
raise exception.KeyManagerError(str(ex))
except requests.exceptions.ConnectionError as ex:
raise exception.KeyManagerError(str(ex))
except Exception as ex:
raise exception.KeyManagerError(str(ex))
if resp.status_code in _EXCEPTIONS_BY_CODE:
raise exception.KeyManagerError(resp.reason)
if resp.status_code == requests.codes['forbidden']:
raise exception.Forbidden()
return resp
def create_key_pair(self, context, algorithm, length,
expiration=None, name=None):
"""Creates an asymmetric key pair."""
if algorithm.lower() != 'rsa':
raise NotImplementedError(
"VaultKeyManager only implements rsa keys"
)
priv_key = rsa.generate_private_key(
public_exponent=65537,
key_size=length,
backend=default_backend()
)
private_key = pri_key.PrivateKey(
'RSA',
length,
priv_key.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
)
)
private_key_id = uuid.uuid4().hex
private_id = self._store_key_value(
private_key_id,
private_key
)
# pub_key = priv_key.public_key()
public_key = pub_key.PublicKey(
'RSA',
length,
priv_key.public_key().public_bytes(
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
)
)
public_key_id = uuid.uuid4().hex
public_id = self._store_key_value(
public_key_id,
public_key
)
return private_id, public_id
def _store_key_value(self, key_id, value):
type_value = self._secret_type_dict.get(type(value))
if type_value is None:
raise exception.KeyManagerError(
"Unknown type for value : %r" % value)
record = {
'type': type_value,
'value': binascii.hexlify(value.get_encoded()).decode('utf-8'),
'algorithm': (value.algorithm if hasattr(value, 'algorithm')
else None),
'bit_length': (value.bit_length if hasattr(value, 'bit_length')
else None),
'name': value.name,
'created': value.created
}
if self._kv_version > 1:
record = {'data': record}
self._do_http_request(self._session.post,
self._get_resource_url(key_id),
json=record)
return key_id
def create_key(self, context, algorithm, length, name=None, **kwargs):
"""Creates a symmetric key."""
if length % 8:
msg = _("Length must be multiple of 8.")
raise ValueError(msg)
key_id = uuid.uuid4().hex
key_value = os.urandom((length or 256) // 8)
key = sym_key.SymmetricKey(algorithm,
length or 256,
key_value,
key_id,
name or int(time.time()))
return self._store_key_value(key_id, key)
def store(self, context, key_value, **kwargs):
"""Stores (i.e., registers) a key with the key manager."""
key_id = uuid.uuid4().hex
return self._store_key_value(key_id, key_value)
def get(self, context, key_id, metadata_only=False):
"""Retrieves the key identified by the specified id."""
if not key_id:
raise exception.KeyManagerError('key identifier not provided')
resp = self._do_http_request(self._session.get,
self._get_resource_url(key_id))
if resp.status_code == requests.codes['not_found']:
raise exception.ManagedObjectNotFoundError(uuid=key_id)
record = resp.json()['data']
if self._kv_version > 1:
record = record['data']
key = None if metadata_only else binascii.unhexlify(record['value'])
clazz = None
for type_clazz, type_name in self._secret_type_dict.items():
if type_name == record['type']:
clazz = type_clazz
if clazz is None:
raise exception.KeyManagerError(
"Unknown type : %r" % record['type'])
if hasattr(clazz, 'algorithm') and hasattr(clazz, 'bit_length'):
return clazz(record['algorithm'],
record['bit_length'],
key,
record['name'],
record['created'],
key_id)
else:
return clazz(key,
record['name'],
record['created'],
key_id)
def delete(self, context, key_id, force=False):
"""Represents deleting the key.
The 'force' parameter is not used whatsoever and only kept to allow
consistency with the Barbican implementation.
"""
if not key_id:
raise exception.KeyManagerError('key identifier not provided')
resp = self._do_http_request(self._session.delete,
self._get_resource_url(key_id))
if resp.status_code == requests.codes['not_found']:
raise exception.ManagedObjectNotFoundError(uuid=key_id)
def add_consumer(self, context, managed_object_id, consumer_data):
raise NotImplementedError(
"VaultKeyManager does not implement adding consumers"
)
def remove_consumer(self, context, managed_object_id, consumer_data):
raise NotImplementedError(
"VaultKeyManager does not implement deleting consumers"
)
def list(self, context, object_type=None, metadata_only=False):
"""Lists the managed objects given the criteria."""
if object_type and object_type not in self._secret_type_dict:
msg = _("Invalid secret type: %s") % object_type
raise exception.KeyManagerError(reason=msg)
resp = self._do_http_request(self._session.get,
self._get_resource_url())
if resp.status_code == requests.codes['not_found']:
keys = []
else:
keys = resp.json()['data']['keys']
objects = []
for obj_id in keys:
try:
obj = self.get(context, obj_id, metadata_only=metadata_only)
if object_type is None or isinstance(obj, object_type):
objects.append(obj)
except exception.ManagedObjectNotFoundError as e:
LOG.warning("Error occurred while retrieving object "
"metadata, not adding it to the list: %s", e)
pass
return objects
def list_options_for_discovery(self):
return [(_VAULT_OPT_GROUP, _vault_opts)]