6649144771
There is a useful method in the fernet_utils.py module for creating directories. We can reuse this for the jwt provider implementation. This commit generalizes the logic and moves it into a common place for fernet_utils to use. A subsequent patch introducing JWT functionality will use the same utils. bp json-web-tokens Change-Id: I0e322a745683c43e8329110a165f54d2e998e0c7
298 lines
12 KiB
Python
298 lines
12 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.
|
|
|
|
import base64
|
|
import os
|
|
import stat
|
|
|
|
from cryptography import fernet
|
|
from oslo_log import log
|
|
|
|
from keystone.common import utils
|
|
import keystone.conf
|
|
|
|
|
|
LOG = log.getLogger(__name__)
|
|
|
|
CONF = keystone.conf.CONF
|
|
|
|
# NOTE(lbragstad): In the event there are no encryption keys on disk, let's use
|
|
# a default one until a proper key repository is set up. This allows operators
|
|
# to gracefully upgrade from Mitaka to Newton without a key repository,
|
|
# especially in multi-node deployments. The NULL_KEY is specific to credential
|
|
# encryption only and has absolutely no beneficial purpose outside of easing
|
|
# upgrades.
|
|
NULL_KEY = base64.urlsafe_b64encode(b'\x00' * 32)
|
|
|
|
|
|
class FernetUtils(object):
|
|
|
|
def __init__(self, key_repository=None, max_active_keys=None,
|
|
config_group=None):
|
|
self.key_repository = key_repository
|
|
self.max_active_keys = max_active_keys
|
|
self.config_group = config_group
|
|
|
|
def validate_key_repository(self, requires_write=False):
|
|
"""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 sufficient access to the key repository
|
|
is_valid = (os.access(self.key_repository, os.R_OK) and
|
|
os.access(self.key_repository, os.X_OK))
|
|
if requires_write:
|
|
is_valid = (is_valid and
|
|
os.access(self.key_repository, os.W_OK))
|
|
|
|
if not is_valid:
|
|
LOG.error(
|
|
'Either [%(config_group)s] key_repository does not exist '
|
|
'or Keystone does not have sufficient permission to '
|
|
'access it: %(key_repo)s',
|
|
{'key_repo': self.key_repository,
|
|
'config_group': self.config_group})
|
|
else:
|
|
# ensure the key repository isn't world-readable
|
|
stat_info = os.stat(self.key_repository)
|
|
if(stat_info.st_mode & stat.S_IROTH or
|
|
stat_info.st_mode & stat.S_IXOTH):
|
|
LOG.warning(
|
|
'key_repository is world readable: %s',
|
|
self.key_repository)
|
|
|
|
return is_valid
|
|
|
|
def create_key_directory(self, keystone_user_id=None,
|
|
keystone_group_id=None):
|
|
"""Attempt to create the key directory if it doesn't exist."""
|
|
utils.create_directory(
|
|
self.key_repository, keystone_user_id=keystone_user_id,
|
|
keystone_group_id=keystone_group_id
|
|
)
|
|
|
|
def _create_new_key(self, 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.
|
|
|
|
To avoid disk write failure, this function will create a tmp key file
|
|
first, and then rename it as the valid new key.
|
|
"""
|
|
self._create_tmp_new_key(keystone_user_id, keystone_group_id)
|
|
self._become_valid_new_key()
|
|
|
|
def _create_tmp_new_key(self, keystone_user_id, keystone_group_id):
|
|
"""Securely create a new tmp encryption key.
|
|
|
|
This created key is not effective until _become_valid_new_key().
|
|
"""
|
|
key = fernet.Fernet.generate_key() # key is bytes
|
|
|
|
# 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(
|
|
'Unable to change the ownership of the new key without a '
|
|
'keystone user ID and keystone group ID both being provided: '
|
|
'%s', self.key_repository)
|
|
# Determine the file name of the new key
|
|
key_file = os.path.join(self.key_repository, '0.tmp')
|
|
create_success = False
|
|
try:
|
|
with open(key_file, 'w') as f:
|
|
# convert key to str for the file.
|
|
f.write(key.decode('utf-8'))
|
|
f.flush()
|
|
create_success = True
|
|
except IOError:
|
|
LOG.error('Failed to create new temporary key: %s', key_file)
|
|
raise
|
|
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)
|
|
# Deal with the tmp key file
|
|
if not create_success and os.access(key_file, os.F_OK):
|
|
os.remove(key_file)
|
|
|
|
LOG.info('Created a new temporary key: %s', key_file)
|
|
|
|
def _become_valid_new_key(self):
|
|
"""Make the tmp new key a valid new key.
|
|
|
|
The tmp new key must be created by _create_tmp_new_key().
|
|
"""
|
|
tmp_key_file = os.path.join(self.key_repository, '0.tmp')
|
|
valid_key_file = os.path.join(self.key_repository, '0')
|
|
|
|
os.rename(tmp_key_file, valid_key_file)
|
|
|
|
LOG.info('Become a valid new key: %s', valid_key_file)
|
|
|
|
def _get_key_files(self, key_repo):
|
|
key_files = dict()
|
|
keys = dict()
|
|
for filename in os.listdir(key_repo):
|
|
path = os.path.join(key_repo, str(filename))
|
|
if os.path.isfile(path):
|
|
with open(path, 'r') as key_file:
|
|
try:
|
|
key_id = int(filename)
|
|
except ValueError: # nosec : name is not a number
|
|
pass
|
|
else:
|
|
key = key_file.read()
|
|
if len(key) == 0:
|
|
LOG.warning('Ignoring empty key found in key '
|
|
'repository: %s', path)
|
|
continue
|
|
key_files[key_id] = path
|
|
keys[key_id] = key
|
|
return key_files, keys
|
|
|
|
def initialize_key_repository(self, 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(self.key_repository, '0'),
|
|
os.F_OK):
|
|
LOG.info('Key repository is already initialized; aborting.')
|
|
return
|
|
|
|
# bootstrap an existing key
|
|
self._create_new_key(keystone_user_id, keystone_group_id)
|
|
|
|
# ensure that we end up with a primary and secondary key
|
|
self.rotate_keys(keystone_user_id, keystone_group_id)
|
|
|
|
def rotate_keys(self, 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, _ = self._get_key_files(self.key_repository)
|
|
|
|
LOG.info('Starting key rotation with %(count)s key files: '
|
|
'%(list)s', {
|
|
'count': len(key_files),
|
|
'list': list(key_files.values())})
|
|
|
|
# add a tmp new key to the rotation, which will be the *next* primary
|
|
self._create_tmp_new_key(keystone_user_id, keystone_group_id)
|
|
|
|
# determine the number of the new primary key
|
|
current_primary_key = max(key_files.keys())
|
|
LOG.info('Current primary key is: %s', current_primary_key)
|
|
new_primary_key = current_primary_key + 1
|
|
LOG.info('Next primary key will be: %s', new_primary_key)
|
|
|
|
# promote the next primary key to be the primary
|
|
os.rename(
|
|
os.path.join(self.key_repository, '0'),
|
|
os.path.join(self.key_repository, str(new_primary_key))
|
|
)
|
|
key_files.pop(0)
|
|
key_files[new_primary_key] = os.path.join(
|
|
self.key_repository,
|
|
str(new_primary_key))
|
|
LOG.info('Promoted key 0 to be the primary: %s', new_primary_key)
|
|
|
|
# rename the tmp key to the real staged key
|
|
self._become_valid_new_key()
|
|
|
|
max_active_keys = self.max_active_keys
|
|
|
|
# purge excess keys
|
|
|
|
# Note that key_files doesn't contain the new active key that was
|
|
# created, only the old active keys.
|
|
keys = sorted(key_files.keys(), reverse=True)
|
|
while len(keys) > (max_active_keys - 1):
|
|
index_to_purge = keys.pop()
|
|
key_to_purge = key_files[index_to_purge]
|
|
LOG.info('Excess key to purge: %s', key_to_purge)
|
|
os.remove(key_to_purge)
|
|
|
|
def load_keys(self, use_null_key=False):
|
|
"""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.
|
|
|
|
:param use_null_key: If true, a known key containing null bytes will be
|
|
appended to the list of returned keys.
|
|
|
|
"""
|
|
if not self.validate_key_repository():
|
|
if use_null_key:
|
|
return [NULL_KEY]
|
|
return []
|
|
|
|
# build a dictionary of key_number:encryption_key pairs
|
|
_, keys = self._get_key_files(self.key_repository)
|
|
|
|
if len(keys) != self.max_active_keys:
|
|
# Once the number of keys matches max_active_keys, this log entry
|
|
# is too repetitive to be useful. Also note that it only makes
|
|
# sense to log this message for tokens since credentials doesn't
|
|
# have a `max_active_key` configuration option.
|
|
if self.key_repository == CONF.fernet_tokens.key_repository:
|
|
msg = ('Loaded %(count)d Fernet keys from %(dir)s, but '
|
|
'`[fernet_tokens] max_active_keys = %(max)d`; perhaps '
|
|
'there have not been enough key rotations to reach '
|
|
'`max_active_keys` yet?')
|
|
LOG.debug(msg, {
|
|
'count': len(keys),
|
|
'max': self.max_active_keys,
|
|
'dir': self.key_repository})
|
|
|
|
# return the encryption_keys, sorted by key number, descending
|
|
key_list = [keys[x] for x in sorted(keys.keys(), reverse=True)]
|
|
|
|
if use_null_key:
|
|
key_list.append(NULL_KEY)
|
|
|
|
return key_list
|