diff options
author | WuKong <rebirthmonkey@gmail.com> | 2015-06-30 18:47:29 +0200 |
---|---|---|
committer | WuKong <rebirthmonkey@gmail.com> | 2015-06-30 18:47:29 +0200 |
commit | b8c756ecdd7cced1db4300935484e8c83701c82e (patch) | |
tree | 87e51107d82b217ede145de9d9d59e2100725bd7 /keystone-moon/keystone/token/providers/fernet | |
parent | c304c773bae68fb854ed9eab8fb35c4ef17cf136 (diff) |
migrate moon code from github to opnfv
Change-Id: Ice53e368fd1114d56a75271aa9f2e598e3eba604
Signed-off-by: WuKong <rebirthmonkey@gmail.com>
Diffstat (limited to 'keystone-moon/keystone/token/providers/fernet')
4 files changed, 1068 insertions, 0 deletions
diff --git a/keystone-moon/keystone/token/providers/fernet/__init__.py b/keystone-moon/keystone/token/providers/fernet/__init__.py new file mode 100644 index 00000000..953ef624 --- /dev/null +++ b/keystone-moon/keystone/token/providers/fernet/__init__.py @@ -0,0 +1,13 @@ +# 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 keystone.token.providers.fernet.core import * # noqa diff --git a/keystone-moon/keystone/token/providers/fernet/core.py b/keystone-moon/keystone/token/providers/fernet/core.py new file mode 100644 index 00000000..b1da263b --- /dev/null +++ b/keystone-moon/keystone/token/providers/fernet/core.py @@ -0,0 +1,267 @@ +# 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 oslo_config import cfg +from oslo_log import log + +from keystone.common import dependency +from keystone.contrib import federation +from keystone import exception +from keystone.i18n import _ +from keystone.token import provider +from keystone.token.providers import common +from keystone.token.providers.fernet import token_formatters as tf + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +@dependency.requires('trust_api') +class Provider(common.BaseProvider): + def __init__(self, *args, **kwargs): + super(Provider, self).__init__(*args, **kwargs) + + self.token_formatter = tf.TokenFormatter() + + def needs_persistence(self): + """Should the token be written to a backend.""" + return False + + def issue_v2_token(self, token_ref, roles_ref=None, catalog_ref=None): + """Issue a V2 formatted token. + + :param token_ref: reference describing the token + :param roles_ref: reference describing the roles for the token + :param catalog_ref: reference describing the token's catalog + :returns: tuple containing the ID of the token and the token data + + """ + # TODO(lbragstad): Currently, Fernet tokens don't support bind in the + # token format. Raise a 501 if we're dealing with bind. + if token_ref.get('bind'): + raise exception.NotImplemented() + + user_id = token_ref['user']['id'] + # Default to password since methods not provided by token_ref + method_names = ['password'] + project_id = None + # Verify that tenant is not None in token_ref + if token_ref.get('tenant'): + project_id = token_ref['tenant']['id'] + + parent_audit_id = token_ref.get('parent_audit_id') + # If parent_audit_id is defined then a token authentication was made + if parent_audit_id: + method_names.append('token') + + audit_ids = provider.audit_info(parent_audit_id) + + # Get v3 token data and exclude building v3 specific catalog. This is + # due to the fact that the V2TokenDataHelper.format_token() method + # doesn't build any of the token_reference from other Keystone APIs. + # Instead, it builds it from what is persisted in the token reference. + # Here we are going to leverage the V3TokenDataHelper.get_token_data() + # method written for V3 because it goes through and populates the token + # reference dynamically. Once we have a V3 token reference, we can + # attempt to convert it to a V2 token response. + v3_token_data = self.v3_token_data_helper.get_token_data( + user_id, + method_names, + project_id=project_id, + token=token_ref, + include_catalog=False, + audit_info=audit_ids) + + expires_at = v3_token_data['token']['expires_at'] + token_id = self.token_formatter.create_token(user_id, expires_at, + audit_ids, + methods=method_names, + project_id=project_id) + # Convert v3 to v2 token data and build v2 catalog + token_data = self.v2_token_data_helper.v3_to_v2_token(token_id, + v3_token_data) + + return token_id, token_data + + def _build_federated_info(self, token_data): + """Extract everything needed for federated tokens. + + This dictionary is passed to the FederatedPayload token formatter, + which unpacks the values and builds the Fernet token. + + """ + group_ids = token_data.get('user', {}).get( + federation.FEDERATION, {}).get('groups') + idp_id = token_data.get('user', {}).get( + federation.FEDERATION, {}).get('identity_provider', {}).get('id') + protocol_id = token_data.get('user', {}).get( + federation.FEDERATION, {}).get('protocol', {}).get('id') + if not group_ids: + group_ids = list() + federated_dict = dict(group_ids=group_ids, idp_id=idp_id, + protocol_id=protocol_id) + return federated_dict + + def _rebuild_federated_info(self, federated_dict, user_id): + """Format federated information into the token reference. + + The federated_dict is passed back from the FederatedPayload token + formatter. The responsibility of this method is to format the + information passed back from the token formatter into the token + reference before constructing the token data from the + V3TokenDataHelper. + + """ + g_ids = federated_dict['group_ids'] + idp_id = federated_dict['idp_id'] + protocol_id = federated_dict['protocol_id'] + federated_info = dict(groups=g_ids, + identity_provider=dict(id=idp_id), + protocol=dict(id=protocol_id)) + token_dict = {'user': {federation.FEDERATION: federated_info}} + token_dict['user']['id'] = user_id + token_dict['user']['name'] = user_id + return token_dict + + def issue_v3_token(self, user_id, method_names, expires_at=None, + project_id=None, domain_id=None, auth_context=None, + trust=None, metadata_ref=None, include_catalog=True, + parent_audit_id=None): + """Issue a V3 formatted token. + + Here is where we need to detect what is given to us, and what kind of + token the user is expecting. Depending on the outcome of that, we can + pass all the information to be packed to the proper token format + handler. + + :param user_id: ID of the user + :param method_names: method of authentication + :param expires_at: token expiration time + :param project_id: ID of the project being scoped to + :param domain_id: ID of the domain being scoped to + :param auth_context: authentication context + :param trust: ID of the trust + :param metadata_ref: metadata reference + :param include_catalog: return the catalog in the response if True, + otherwise don't return the catalog + :param parent_audit_id: ID of the patent audit entity + :returns: tuple containing the id of the token and the token data + + """ + # TODO(lbragstad): Currently, Fernet tokens don't support bind in the + # token format. Raise a 501 if we're dealing with bind. + if auth_context.get('bind'): + raise exception.NotImplemented() + + token_ref = None + # NOTE(lbragstad): This determines if we are dealing with a federated + # token or not. The groups for the user will be in the returned token + # reference. + federated_dict = None + if auth_context and self._is_mapped_token(auth_context): + token_ref = self._handle_mapped_tokens( + auth_context, project_id, domain_id) + federated_dict = self._build_federated_info(token_ref) + + token_data = self.v3_token_data_helper.get_token_data( + user_id, + method_names, + auth_context.get('extras') if auth_context else None, + domain_id=domain_id, + project_id=project_id, + expires=expires_at, + trust=trust, + bind=auth_context.get('bind') if auth_context else None, + token=token_ref, + include_catalog=include_catalog, + audit_info=parent_audit_id) + + token = self.token_formatter.create_token( + user_id, + token_data['token']['expires_at'], + token_data['token']['audit_ids'], + methods=method_names, + domain_id=domain_id, + project_id=project_id, + trust_id=token_data['token'].get('OS-TRUST:trust', {}).get('id'), + federated_info=federated_dict) + return token, token_data + + def validate_v2_token(self, token_ref): + """Validate a V2 formatted token. + + :param token_ref: reference describing the token to validate + :returns: the token data + :raises keystone.exception.Unauthorized: if v3 token is used + + """ + (user_id, methods, + audit_ids, domain_id, + project_id, trust_id, + federated_info, created_at, + expires_at) = self.token_formatter.validate_token(token_ref) + + if trust_id or domain_id or federated_info: + msg = _('This is not a v2.0 Fernet token. Use v3 for trust, ' + 'domain, or federated tokens.') + raise exception.Unauthorized(msg) + + v3_token_data = self.v3_token_data_helper.get_token_data( + user_id, + methods, + project_id=project_id, + expires=expires_at, + issued_at=created_at, + token=token_ref, + include_catalog=False, + audit_info=audit_ids) + return self.v2_token_data_helper.v3_to_v2_token(token_ref, + v3_token_data) + + def validate_v3_token(self, token): + """Validate a V3 formatted token. + + :param token: a string describing the token to validate + :returns: the token data + :raises keystone.exception.Unauthorized: if token format version isn't + supported + + """ + (user_id, methods, audit_ids, domain_id, project_id, trust_id, + federated_info, created_at, expires_at) = ( + self.token_formatter.validate_token(token)) + + token_dict = None + if federated_info: + token_dict = self._rebuild_federated_info(federated_info, user_id) + trust_ref = self.trust_api.get_trust(trust_id) + + return self.v3_token_data_helper.get_token_data( + user_id, + method_names=methods, + domain_id=domain_id, + project_id=project_id, + issued_at=created_at, + expires=expires_at, + trust=trust_ref, + token=token_dict, + audit_info=audit_ids) + + def _get_token_id(self, token_data): + """Generate the token_id based upon the data in token_data. + + :param token_data: token information + :type token_data: dict + :raises keystone.exception.NotImplemented: when called + """ + raise exception.NotImplemented() diff --git a/keystone-moon/keystone/token/providers/fernet/token_formatters.py b/keystone-moon/keystone/token/providers/fernet/token_formatters.py new file mode 100644 index 00000000..50960923 --- /dev/null +++ b/keystone-moon/keystone/token/providers/fernet/token_formatters.py @@ -0,0 +1,545 @@ +# 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 base64 +import datetime +import struct +import uuid + +from cryptography import fernet +import msgpack +from oslo_config import cfg +from oslo_log import log +from oslo_utils import timeutils +import six +from six.moves import urllib + +from keystone.auth import plugins as auth_plugins +from keystone import exception +from keystone.i18n import _ +from keystone.token import provider +from keystone.token.providers.fernet import utils + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +# Fernet byte indexes as as computed by pypi/keyless_fernet and defined in +# https://github.com/fernet/spec +TIMESTAMP_START = 1 +TIMESTAMP_END = 9 + + +class TokenFormatter(object): + """Packs and unpacks payloads into tokens for transport.""" + + @property + def crypto(self): + """Return a cryptography instance. + + You can extend this class with a custom crypto @property to provide + your own token encoding / decoding. For example, using a different + cryptography library (e.g. ``python-keyczar``) or to meet arbitrary + security requirements. + + This @property just needs to return an object that implements + ``encrypt(plaintext)`` and ``decrypt(ciphertext)``. + + """ + keys = utils.load_keys() + + if not keys: + raise exception.KeysNotFound() + + fernet_instances = [fernet.Fernet(key) for key in utils.load_keys()] + return fernet.MultiFernet(fernet_instances) + + def pack(self, payload): + """Pack a payload for transport as a token.""" + # base64 padding (if any) is not URL-safe + return urllib.parse.quote(self.crypto.encrypt(payload)) + + def unpack(self, token): + """Unpack a token, and validate the payload.""" + token = urllib.parse.unquote(six.binary_type(token)) + + try: + return self.crypto.decrypt(token) + except fernet.InvalidToken as e: + raise exception.Unauthorized(six.text_type(e)) + + @classmethod + def creation_time(cls, fernet_token): + """Returns the creation time of a valid Fernet token.""" + # tokens may be transmitted as Unicode, but they're just ASCII + # (pypi/cryptography will refuse to operate on Unicode input) + fernet_token = six.binary_type(fernet_token) + + # the base64 padding on fernet tokens is made URL-safe + fernet_token = urllib.parse.unquote(fernet_token) + + # fernet tokens are base64 encoded and the padding made URL-safe + token_bytes = base64.urlsafe_b64decode(fernet_token) + + # slice into the byte array to get just the timestamp + timestamp_bytes = token_bytes[TIMESTAMP_START:TIMESTAMP_END] + + # convert those bytes to an integer + # (it's a 64-bit "unsigned long long int" in C) + timestamp_int = struct.unpack(">Q", timestamp_bytes)[0] + + # and with an integer, it's trivial to produce a datetime object + created_at = datetime.datetime.utcfromtimestamp(timestamp_int) + + return created_at + + def create_token(self, user_id, expires_at, audit_ids, methods=None, + domain_id=None, project_id=None, trust_id=None, + federated_info=None): + """Given a set of payload attributes, generate a Fernet token.""" + if trust_id: + version = TrustScopedPayload.version + payload = TrustScopedPayload.assemble( + user_id, + methods, + project_id, + expires_at, + audit_ids, + trust_id) + elif federated_info: + version = FederatedPayload.version + payload = FederatedPayload.assemble( + user_id, + methods, + expires_at, + audit_ids, + federated_info) + elif project_id: + version = ProjectScopedPayload.version + payload = ProjectScopedPayload.assemble( + user_id, + methods, + project_id, + expires_at, + audit_ids) + elif domain_id: + version = DomainScopedPayload.version + payload = DomainScopedPayload.assemble( + user_id, + methods, + domain_id, + expires_at, + audit_ids) + else: + version = UnscopedPayload.version + payload = UnscopedPayload.assemble( + user_id, + methods, + expires_at, + audit_ids) + + versioned_payload = (version,) + payload + serialized_payload = msgpack.packb(versioned_payload) + token = self.pack(serialized_payload) + + return token + + def validate_token(self, token): + """Validates a Fernet token and returns the payload attributes.""" + # Convert v2 unicode token to a string + if not isinstance(token, six.binary_type): + token = token.encode('ascii') + + serialized_payload = self.unpack(token) + versioned_payload = msgpack.unpackb(serialized_payload) + version, payload = versioned_payload[0], versioned_payload[1:] + + # depending on the formatter, these may or may not be defined + domain_id = None + project_id = None + trust_id = None + federated_info = None + + if version == UnscopedPayload.version: + (user_id, methods, expires_at, audit_ids) = ( + UnscopedPayload.disassemble(payload)) + elif version == DomainScopedPayload.version: + (user_id, methods, domain_id, expires_at, audit_ids) = ( + DomainScopedPayload.disassemble(payload)) + elif version == ProjectScopedPayload.version: + (user_id, methods, project_id, expires_at, audit_ids) = ( + ProjectScopedPayload.disassemble(payload)) + elif version == TrustScopedPayload.version: + (user_id, methods, project_id, expires_at, audit_ids, trust_id) = ( + TrustScopedPayload.disassemble(payload)) + elif version == FederatedPayload.version: + (user_id, methods, expires_at, audit_ids, federated_info) = ( + FederatedPayload.disassemble(payload)) + else: + # If the token_format is not recognized, raise Unauthorized. + raise exception.Unauthorized(_( + 'This is not a recognized Fernet payload version: %s') % + version) + + # rather than appearing in the payload, the creation time is encoded + # into the token format itself + created_at = TokenFormatter.creation_time(token) + created_at = timeutils.isotime(at=created_at, subsecond=True) + expires_at = timeutils.parse_isotime(expires_at) + expires_at = timeutils.isotime(at=expires_at, subsecond=True) + + return (user_id, methods, audit_ids, domain_id, project_id, trust_id, + federated_info, created_at, expires_at) + + +class BasePayload(object): + # each payload variant should have a unique version + version = None + + @classmethod + def assemble(cls, *args): + """Assemble the payload of a token. + + :param args: whatever data should go into the payload + :returns: the payload of a token + + """ + raise NotImplementedError() + + @classmethod + def disassemble(cls, payload): + """Disassemble an unscoped payload into the component data. + + :param payload: this variant of payload + :returns: a tuple of the payloads component data + + """ + raise NotImplementedError() + + @classmethod + def convert_uuid_hex_to_bytes(cls, uuid_string): + """Compress UUID formatted strings to bytes. + + :param uuid_string: uuid string to compress to bytes + :returns: a byte representation of the uuid + + """ + # TODO(lbragstad): Wrap this in an exception. Not sure what the case + # would be where we couldn't handle what we've been given but incase + # the integrity of the token has been compromised. + uuid_obj = uuid.UUID(uuid_string) + return uuid_obj.bytes + + @classmethod + def convert_uuid_bytes_to_hex(cls, uuid_byte_string): + """Generate uuid.hex format based on byte string. + + :param uuid_byte_string: uuid string to generate from + :returns: uuid hex formatted string + + """ + # TODO(lbragstad): Wrap this in an exception. Not sure what the case + # would be where we couldn't handle what we've been given but incase + # the integrity of the token has been compromised. + uuid_obj = uuid.UUID(bytes=uuid_byte_string) + return uuid_obj.hex + + @classmethod + def _convert_time_string_to_int(cls, time_string): + """Convert a time formatted string to a timestamp integer. + + :param time_string: time formatted string + :returns: an integer timestamp + + """ + time_object = timeutils.parse_isotime(time_string) + return (timeutils.normalize_time(time_object) - + datetime.datetime.utcfromtimestamp(0)).total_seconds() + + @classmethod + def _convert_int_to_time_string(cls, time_int): + """Convert a timestamp integer to a string. + + :param time_int: integer representing timestamp + :returns: a time formatted strings + + """ + time_object = datetime.datetime.utcfromtimestamp(int(time_int)) + return timeutils.isotime(time_object) + + @classmethod + def attempt_convert_uuid_hex_to_bytes(cls, value): + """Attempt to convert value to bytes or return value. + + :param value: value to attempt to convert to bytes + :returns: uuid value in bytes or value + + """ + try: + return cls.convert_uuid_hex_to_bytes(value) + except ValueError: + # this might not be a UUID, depending on the situation (i.e. + # federation) + return value + + @classmethod + def attempt_convert_uuid_bytes_to_hex(cls, value): + """Attempt to convert value to hex or return value. + + :param value: value to attempt to convert to hex + :returns: uuid value in hex or value + + """ + try: + return cls.convert_uuid_bytes_to_hex(value) + except ValueError: + return value + + +class UnscopedPayload(BasePayload): + version = 0 + + @classmethod + def assemble(cls, user_id, methods, expires_at, audit_ids): + """Assemble the payload of an unscoped token. + + :param user_id: identifier of the user in the token request + :param methods: list of authentication methods used + :param expires_at: datetime of the token's expiration + :param audit_ids: list of the token's audit IDs + :returns: the payload of an unscoped token + + """ + b_user_id = cls.convert_uuid_hex_to_bytes(user_id) + methods = auth_plugins.convert_method_list_to_integer(methods) + expires_at_int = cls._convert_time_string_to_int(expires_at) + b_audit_ids = list(map(provider.random_urlsafe_str_to_bytes, + audit_ids)) + return (b_user_id, methods, expires_at_int, b_audit_ids) + + @classmethod + def disassemble(cls, payload): + """Disassemble an unscoped payload into the component data. + + :param payload: the payload of an unscoped token + :return: a tuple containing the user_id, auth methods, expires_at, and + audit_ids + + """ + user_id = cls.convert_uuid_bytes_to_hex(payload[0]) + methods = auth_plugins.convert_integer_to_method_list(payload[1]) + expires_at_str = cls._convert_int_to_time_string(payload[2]) + audit_ids = list(map(provider.base64_encode, payload[3])) + return (user_id, methods, expires_at_str, audit_ids) + + +class DomainScopedPayload(BasePayload): + version = 1 + + @classmethod + def assemble(cls, user_id, methods, domain_id, expires_at, audit_ids): + """Assemble the payload of a domain-scoped token. + + :param user_id: ID of the user in the token request + :param methods: list of authentication methods used + :param domain_id: ID of the domain to scope to + :param expires_at: datetime of the token's expiration + :param audit_ids: list of the token's audit IDs + :returns: the payload of a domain-scoped token + + """ + b_user_id = cls.convert_uuid_hex_to_bytes(user_id) + methods = auth_plugins.convert_method_list_to_integer(methods) + try: + b_domain_id = cls.convert_uuid_hex_to_bytes(domain_id) + except ValueError: + # the default domain ID is configurable, and probably isn't a UUID + if domain_id == CONF.identity.default_domain_id: + b_domain_id = domain_id + else: + raise + expires_at_int = cls._convert_time_string_to_int(expires_at) + b_audit_ids = list(map(provider.random_urlsafe_str_to_bytes, + audit_ids)) + return (b_user_id, methods, b_domain_id, expires_at_int, b_audit_ids) + + @classmethod + def disassemble(cls, payload): + """Disassemble a payload into the component data. + + :param payload: the payload of a token + :return: a tuple containing the user_id, auth methods, domain_id, + expires_at_str, and audit_ids + + """ + user_id = cls.convert_uuid_bytes_to_hex(payload[0]) + methods = auth_plugins.convert_integer_to_method_list(payload[1]) + try: + domain_id = cls.convert_uuid_bytes_to_hex(payload[2]) + except ValueError: + # the default domain ID is configurable, and probably isn't a UUID + if payload[2] == CONF.identity.default_domain_id: + domain_id = payload[2] + else: + raise + expires_at_str = cls._convert_int_to_time_string(payload[3]) + audit_ids = list(map(provider.base64_encode, payload[4])) + + return (user_id, methods, domain_id, expires_at_str, audit_ids) + + +class ProjectScopedPayload(BasePayload): + version = 2 + + @classmethod + def assemble(cls, user_id, methods, project_id, expires_at, audit_ids): + """Assemble the payload of a project-scoped token. + + :param user_id: ID of the user in the token request + :param methods: list of authentication methods used + :param project_id: ID of the project to scope to + :param expires_at: datetime of the token's expiration + :param audit_ids: list of the token's audit IDs + :returns: the payload of a project-scoped token + + """ + b_user_id = cls.convert_uuid_hex_to_bytes(user_id) + methods = auth_plugins.convert_method_list_to_integer(methods) + b_project_id = cls.convert_uuid_hex_to_bytes(project_id) + expires_at_int = cls._convert_time_string_to_int(expires_at) + b_audit_ids = list(map(provider.random_urlsafe_str_to_bytes, + audit_ids)) + return (b_user_id, methods, b_project_id, expires_at_int, b_audit_ids) + + @classmethod + def disassemble(cls, payload): + """Disassemble a payload into the component data. + + :param payload: the payload of a token + :return: a tuple containing the user_id, auth methods, project_id, + expires_at_str, and audit_ids + + """ + user_id = cls.convert_uuid_bytes_to_hex(payload[0]) + methods = auth_plugins.convert_integer_to_method_list(payload[1]) + project_id = cls.convert_uuid_bytes_to_hex(payload[2]) + expires_at_str = cls._convert_int_to_time_string(payload[3]) + audit_ids = list(map(provider.base64_encode, payload[4])) + + return (user_id, methods, project_id, expires_at_str, audit_ids) + + +class TrustScopedPayload(BasePayload): + version = 3 + + @classmethod + def assemble(cls, user_id, methods, project_id, expires_at, audit_ids, + trust_id): + """Assemble the payload of a trust-scoped token. + + :param user_id: ID of the user in the token request + :param methods: list of authentication methods used + :param project_id: ID of the project to scope to + :param expires_at: datetime of the token's expiration + :param audit_ids: list of the token's audit IDs + :param trust_id: ID of the trust in effect + :returns: the payload of a trust-scoped token + + """ + b_user_id = cls.convert_uuid_hex_to_bytes(user_id) + methods = auth_plugins.convert_method_list_to_integer(methods) + b_project_id = cls.convert_uuid_hex_to_bytes(project_id) + b_trust_id = cls.convert_uuid_hex_to_bytes(trust_id) + expires_at_int = cls._convert_time_string_to_int(expires_at) + b_audit_ids = list(map(provider.random_urlsafe_str_to_bytes, + audit_ids)) + + return (b_user_id, methods, b_project_id, expires_at_int, b_audit_ids, + b_trust_id) + + @classmethod + def disassemble(cls, payload): + """Validate a trust-based payload. + + :param token_string: a string representing the token + :returns: a tuple containing the user_id, auth methods, project_id, + expires_at_str, audit_ids, and trust_id + + """ + user_id = cls.convert_uuid_bytes_to_hex(payload[0]) + methods = auth_plugins.convert_integer_to_method_list(payload[1]) + project_id = cls.convert_uuid_bytes_to_hex(payload[2]) + expires_at_str = cls._convert_int_to_time_string(payload[3]) + audit_ids = list(map(provider.base64_encode, payload[4])) + trust_id = cls.convert_uuid_bytes_to_hex(payload[5]) + + return (user_id, methods, project_id, expires_at_str, audit_ids, + trust_id) + + +class FederatedPayload(BasePayload): + version = 4 + + @classmethod + def assemble(cls, user_id, methods, expires_at, audit_ids, federated_info): + """Assemble the payload of a federated token. + + :param user_id: ID of the user in the token request + :param methods: list of authentication methods used + :param expires_at: datetime of the token's expiration + :param audit_ids: list of the token's audit IDs + :param federated_info: dictionary containing group IDs, the identity + provider ID, protocol ID, and federated domain + ID + :returns: the payload of a federated token + + """ + def pack_group_ids(group_dict): + return cls.convert_uuid_hex_to_bytes(group_dict['id']) + + b_user_id = cls.attempt_convert_uuid_hex_to_bytes(user_id) + methods = auth_plugins.convert_method_list_to_integer(methods) + b_group_ids = map(pack_group_ids, federated_info['group_ids']) + b_idp_id = cls.attempt_convert_uuid_hex_to_bytes( + federated_info['idp_id']) + protocol_id = federated_info['protocol_id'] + expires_at_int = cls._convert_time_string_to_int(expires_at) + b_audit_ids = map(provider.random_urlsafe_str_to_bytes, audit_ids) + + return (b_user_id, methods, b_group_ids, b_idp_id, protocol_id, + expires_at_int, b_audit_ids) + + @classmethod + def disassemble(cls, payload): + """Validate a federated paylod. + + :param token_string: a string representing the token + :return: a tuple containing the user_id, auth methods, audit_ids, and + a dictionary containing federated information such as the the + group IDs, the identity provider ID, the protocol ID, and the + federated domain ID + + """ + def unpack_group_ids(group_id_in_bytes): + group_id = cls.convert_uuid_bytes_to_hex(group_id_in_bytes) + return {'id': group_id} + + user_id = cls.attempt_convert_uuid_bytes_to_hex(payload[0]) + methods = auth_plugins.convert_integer_to_method_list(payload[1]) + group_ids = map(unpack_group_ids, payload[2]) + idp_id = cls.attempt_convert_uuid_bytes_to_hex(payload[3]) + protocol_id = payload[4] + expires_at_str = cls._convert_int_to_time_string(payload[5]) + audit_ids = map(provider.base64_encode, payload[6]) + federated_info = dict(group_ids=group_ids, idp_id=idp_id, + protocol_id=protocol_id) + return (user_id, methods, expires_at_str, audit_ids, federated_info) diff --git a/keystone-moon/keystone/token/providers/fernet/utils.py b/keystone-moon/keystone/token/providers/fernet/utils.py new file mode 100644 index 00000000..56624ee5 --- /dev/null +++ b/keystone-moon/keystone/token/providers/fernet/utils.py @@ -0,0 +1,243 @@ +# 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 os +import stat + +from cryptography import fernet +from oslo_config import cfg +from oslo_log import log + +from keystone.i18n import _LE, _LW, _LI + + +LOG = log.getLogger(__name__) + +CONF = cfg.CONF + + +def validate_key_repository(): + """Validate permissions on the key repository directory.""" + # NOTE(lbragstad): We shouldn't need to check if the directory was passed + # in as None because we don't set allow_no_values to True. + + # ensure current user has full access to the key repository + if (not os.access(CONF.fernet_tokens.key_repository, os.R_OK) or not + os.access(CONF.fernet_tokens.key_repository, os.W_OK) or not + os.access(CONF.fernet_tokens.key_repository, os.X_OK)): + LOG.error( + _LE('Either [fernet_tokens] key_repository does not exist or ' + 'Keystone does not have sufficient permission to access it: ' + '%s'), CONF.fernet_tokens.key_repository) + return False + + # ensure the key repository isn't world-readable + stat_info = os.stat(CONF.fernet_tokens.key_repository) + if stat_info.st_mode & stat.S_IROTH or stat_info.st_mode & stat.S_IXOTH: + LOG.warning(_LW( + '[fernet_tokens] key_repository is world readable: %s'), + CONF.fernet_tokens.key_repository) + + return True + + +def _convert_to_integers(id_value): + """Cast user and group system identifiers to integers.""" + # NOTE(lbragstad) os.chown() will raise a TypeError here if + # keystone_user_id and keystone_group_id are not integers. Let's + # cast them to integers if we can because it's possible to pass non-integer + # values into the fernet_setup utility. + try: + id_int = int(id_value) + except ValueError as e: + msg = ('Unable to convert Keystone user or group ID. Error: %s', e) + LOG.error(msg) + raise + + return id_int + + +def create_key_directory(keystone_user_id=None, keystone_group_id=None): + """If the configured key directory does not exist, attempt to create it.""" + if not os.access(CONF.fernet_tokens.key_repository, os.F_OK): + LOG.info(_LI( + '[fernet_tokens] key_repository does not appear to exist; ' + 'attempting to create it')) + + try: + os.makedirs(CONF.fernet_tokens.key_repository, 0o700) + except OSError: + LOG.error(_LE( + 'Failed to create [fernet_tokens] key_repository: either it ' + 'already exists or you don\'t have sufficient permissions to ' + 'create it')) + + if keystone_user_id and keystone_group_id: + os.chown( + CONF.fernet_tokens.key_repository, + keystone_user_id, + keystone_group_id) + elif keystone_user_id or keystone_group_id: + LOG.warning(_LW( + 'Unable to change the ownership of [fernet_tokens] ' + 'key_repository without a keystone user ID and keystone group ' + 'ID both being provided: %s') % + CONF.fernet_tokens.key_repository) + + +def _create_new_key(keystone_user_id, keystone_group_id): + """Securely create a new encryption key. + + Create a new key that is readable by the Keystone group and Keystone user. + """ + key = fernet.Fernet.generate_key() + + # This ensures the key created is not world-readable + old_umask = os.umask(0o177) + if keystone_user_id and keystone_group_id: + old_egid = os.getegid() + old_euid = os.geteuid() + os.setegid(keystone_group_id) + os.seteuid(keystone_user_id) + elif keystone_user_id or keystone_group_id: + LOG.warning(_LW( + 'Unable to change the ownership of the new key without a keystone ' + 'user ID and keystone group ID both being provided: %s') % + CONF.fernet_tokens.key_repository) + # Determine the file name of the new key + key_file = os.path.join(CONF.fernet_tokens.key_repository, '0') + try: + with open(key_file, 'w') as f: + f.write(key) + finally: + # After writing the key, set the umask back to it's original value. Do + # the same with group and user identifiers if a Keystone group or user + # was supplied. + os.umask(old_umask) + if keystone_user_id and keystone_group_id: + os.seteuid(old_euid) + os.setegid(old_egid) + + LOG.info(_LI('Created a new key: %s'), key_file) + + +def initialize_key_repository(keystone_user_id=None, keystone_group_id=None): + """Create a key repository and bootstrap it with a key. + + :param keystone_user_id: User ID of the Keystone user. + :param keystone_group_id: Group ID of the Keystone user. + + """ + # make sure we have work to do before proceeding + if os.access(os.path.join(CONF.fernet_tokens.key_repository, '0'), + os.F_OK): + LOG.info(_LI('Key repository is already initialized; aborting.')) + return + + # bootstrap an existing key + _create_new_key(keystone_user_id, keystone_group_id) + + # ensure that we end up with a primary and secondary key + rotate_keys(keystone_user_id, keystone_group_id) + + +def rotate_keys(keystone_user_id=None, keystone_group_id=None): + """Create a new primary key and revoke excess active keys. + + :param keystone_user_id: User ID of the Keystone user. + :param keystone_group_id: Group ID of the Keystone user. + + Key rotation utilizes the following behaviors: + + - The highest key number is used as the primary key (used for encryption). + - All keys can be used for decryption. + - New keys are always created as key "0," which serves as a placeholder + before promoting it to be the primary key. + + This strategy allows you to safely perform rotation on one node in a + cluster, before syncing the results of the rotation to all other nodes + (during both key rotation and synchronization, all nodes must recognize all + primary keys). + + """ + # read the list of key files + key_files = dict() + for filename in os.listdir(CONF.fernet_tokens.key_repository): + path = os.path.join(CONF.fernet_tokens.key_repository, str(filename)) + if os.path.isfile(path): + key_files[int(filename)] = path + + LOG.info(_LI('Starting key rotation with %(count)s key files: %(list)s'), { + 'count': len(key_files), + 'list': key_files.values()}) + + # determine the number of the new primary key + current_primary_key = max(key_files.keys()) + LOG.info(_LI('Current primary key is: %s'), current_primary_key) + new_primary_key = current_primary_key + 1 + LOG.info(_LI('Next primary key will be: %s'), new_primary_key) + + # promote the next primary key to be the primary + os.rename( + os.path.join(CONF.fernet_tokens.key_repository, '0'), + os.path.join(CONF.fernet_tokens.key_repository, str(new_primary_key))) + key_files.pop(0) + key_files[new_primary_key] = os.path.join( + CONF.fernet_tokens.key_repository, + str(new_primary_key)) + LOG.info(_LI('Promoted key 0 to be the primary: %s'), new_primary_key) + + # add a new key to the rotation, which will be the *next* primary + _create_new_key(keystone_user_id, keystone_group_id) + + # check for bad configuration + if CONF.fernet_tokens.max_active_keys < 1: + LOG.warning(_LW( + '[fernet_tokens] max_active_keys must be at least 1 to maintain a ' + 'primary key.')) + CONF.fernet_tokens.max_active_keys = 1 + + # purge excess keys + keys = sorted(key_files.keys()) + excess_keys = ( + keys[:len(key_files) - CONF.fernet_tokens.max_active_keys + 1]) + LOG.info(_LI('Excess keys to purge: %s'), excess_keys) + for i in excess_keys: + os.remove(key_files[i]) + + +def load_keys(): + """Load keys from disk into a list. + + The first key in the list is the primary key used for encryption. All + other keys are active secondary keys that can be used for decrypting + tokens. + + """ + if not validate_key_repository(): + return [] + + # build a dictionary of key_number:encryption_key pairs + keys = dict() + for filename in os.listdir(CONF.fernet_tokens.key_repository): + path = os.path.join(CONF.fernet_tokens.key_repository, str(filename)) + if os.path.isfile(path): + with open(path, 'r') as key_file: + keys[int(filename)] = key_file.read() + + LOG.info(_LI( + 'Loaded %(count)s encryption keys from: %(dir)s'), { + 'count': len(keys), + 'dir': CONF.fernet_tokens.key_repository}) + + # return the encryption_keys, sorted by key number, descending + return [keys[x] for x in sorted(keys.keys(), reverse=True)] |