198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
from collections import namedtuple
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from cryptography.hazmat.backends import default_backend
|
|
from django.utils.encoding import smart_str, smart_bytes
|
|
|
|
|
|
__all__ = ['get_encryption_key',
|
|
'encrypt_field', 'decrypt_field',
|
|
'encrypt_value', 'decrypt_value',
|
|
'encrypt_dict']
|
|
|
|
logger = logging.getLogger('awx.main.utils.encryption')
|
|
|
|
|
|
class Fernet256(Fernet):
|
|
'''Not techincally Fernet, but uses the base of the Fernet spec and uses AES-256-CBC
|
|
instead of AES-128-CBC. All other functionality remain identical.
|
|
'''
|
|
def __init__(self, key, backend=None):
|
|
if backend is None:
|
|
backend = default_backend()
|
|
|
|
key = base64.urlsafe_b64decode(key)
|
|
if len(key) != 64:
|
|
raise ValueError(
|
|
"Fernet key must be 64 url-safe base64-encoded bytes."
|
|
)
|
|
|
|
self._signing_key = key[:32]
|
|
self._encryption_key = key[32:]
|
|
self._backend = backend
|
|
|
|
|
|
def get_encryption_key(field_name, pk=None, secret_key=None):
|
|
'''
|
|
Generate key for encrypted password based on field name,
|
|
``settings.SECRET_KEY``, and instance pk (if available).
|
|
|
|
:param pk: (optional) the primary key of the model object;
|
|
can be omitted in situations where you're encrypting a setting
|
|
that is not database-persistent (like a read-only setting)
|
|
'''
|
|
from django.conf import settings
|
|
h = hashlib.sha512()
|
|
h.update(smart_bytes(secret_key or settings.SECRET_KEY))
|
|
if pk is not None:
|
|
h.update(smart_bytes(str(pk)))
|
|
h.update(smart_bytes(field_name))
|
|
return base64.urlsafe_b64encode(h.digest())
|
|
|
|
|
|
def encrypt_value(value, pk=None, secret_key=None):
|
|
#
|
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
|
#
|
|
# !!! BEFORE USING THIS FUNCTION PLEASE READ encrypt_field !!!
|
|
#
|
|
TransientField = namedtuple('TransientField', ['pk', 'value'])
|
|
return encrypt_field(TransientField(pk=pk, value=value), 'value', secret_key=secret_key)
|
|
|
|
|
|
def encrypt_field(instance, field_name, ask=False, subfield=None, secret_key=None):
|
|
#
|
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
|
#
|
|
# !!! PLEASE READ BEFORE USING THIS FUNCTION ANYWHERE !!!
|
|
#
|
|
# You should know that this function is used in various places throughout
|
|
# AWX for symmetric encryption - generally it's used to encrypt sensitive
|
|
# values that we store in the AWX database (such as SSH private keys for
|
|
# credentials).
|
|
#
|
|
# If you're reading this function's code because you're thinking about
|
|
# using it to encrypt *something new*, please remember that AWX has
|
|
# official support for *regenerating* the SECRET_KEY (on which the
|
|
# symmetric key is based):
|
|
#
|
|
# $ awx-manage regenerate_secret_key
|
|
# $ setup.sh -k
|
|
#
|
|
# ...so you'll need to *also* add code to support the
|
|
# migration/re-encryption of these values (the code in question lives in
|
|
# `awx.main.management.commands.regenerate_secret_key`):
|
|
#
|
|
# For example, if you find that you're adding a new database column that is
|
|
# encrypted, in addition to calling `encrypt_field` in the appropriate
|
|
# places, you would also need to update the `awx-manage regenerate_secret_key`
|
|
# so that values are properly migrated when the SECRET_KEY changes.
|
|
#
|
|
# This process *generally* involves adding Python code to the
|
|
# `regenerate_secret_key` command, i.e.,
|
|
#
|
|
# 1. Query the database for existing encrypted values on the appropriate object(s)
|
|
# 2. Decrypting them using the *old* SECRET_KEY
|
|
# 3. Storing newly encrypted values using the *newly generated* SECRET_KEY
|
|
#
|
|
'''
|
|
Return content of the given instance and field name encrypted.
|
|
'''
|
|
try:
|
|
value = instance.inputs[field_name]
|
|
except (TypeError, AttributeError):
|
|
value = getattr(instance, field_name)
|
|
except KeyError:
|
|
raise AttributeError(field_name)
|
|
|
|
if isinstance(value, dict) and subfield is not None:
|
|
value = value[subfield]
|
|
if value is None:
|
|
return None
|
|
value = smart_str(value)
|
|
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
|
return value
|
|
key = get_encryption_key(
|
|
field_name,
|
|
getattr(instance, 'pk', None),
|
|
secret_key=secret_key
|
|
)
|
|
f = Fernet256(key)
|
|
encrypted = f.encrypt(smart_bytes(value))
|
|
b64data = smart_str(base64.b64encode(encrypted))
|
|
tokens = ['$encrypted', 'UTF8', 'AESCBC', b64data]
|
|
return '$'.join(tokens)
|
|
|
|
|
|
def decrypt_value(encryption_key, value):
|
|
raw_data = value[len('$encrypted$'):]
|
|
# If the encrypted string contains a UTF8 marker, discard it
|
|
utf8 = raw_data.startswith('UTF8$')
|
|
if utf8:
|
|
raw_data = raw_data[len('UTF8$'):]
|
|
algo, b64data = raw_data.split('$', 1)
|
|
if algo != 'AESCBC':
|
|
raise ValueError('unsupported algorithm: %s' % algo)
|
|
encrypted = base64.b64decode(b64data)
|
|
f = Fernet256(encryption_key)
|
|
value = f.decrypt(encrypted)
|
|
return smart_str(value)
|
|
|
|
|
|
def decrypt_field(instance, field_name, subfield=None, secret_key=None):
|
|
'''
|
|
Return content of the given instance and field name decrypted.
|
|
'''
|
|
try:
|
|
value = instance.inputs[field_name]
|
|
except (TypeError, AttributeError):
|
|
value = getattr(instance, field_name)
|
|
except KeyError:
|
|
raise AttributeError(field_name)
|
|
|
|
if isinstance(value, dict) and subfield is not None:
|
|
value = value[subfield]
|
|
value = smart_str(value)
|
|
if not value or not value.startswith('$encrypted$'):
|
|
return value
|
|
key = get_encryption_key(
|
|
field_name,
|
|
getattr(instance, 'pk', None),
|
|
secret_key=secret_key
|
|
)
|
|
|
|
try:
|
|
return smart_str(decrypt_value(key, value))
|
|
except InvalidToken:
|
|
logger.exception(
|
|
"Failed to decrypt `%s(pk=%s).%s`; if you've recently restored from "
|
|
"a database backup or are running in a clustered environment, "
|
|
"check that your `SECRET_KEY` value is correct",
|
|
instance.__class__.__name__,
|
|
getattr(instance, 'pk', None),
|
|
field_name,
|
|
exc_info=True
|
|
)
|
|
raise
|
|
|
|
|
|
def encrypt_dict(data, fields):
|
|
'''
|
|
Encrypts all of the dictionary values in `data` under the keys in `fields`
|
|
in-place operation on `data`
|
|
'''
|
|
encrypt_fields = set(data.keys()).intersection(fields)
|
|
for key in encrypt_fields:
|
|
data[key] = encrypt_value(data[key])
|
|
|
|
|
|
def is_encrypted(value):
|
|
if not isinstance(value, str):
|
|
return False
|
|
return value.startswith('$encrypted$') and len(value) > len('$encrypted$')
|