diff --git a/awx/conf/migrations/0004_v320_reencrypt.py b/awx/conf/migrations/0004_v320_reencrypt.py new file mode 100644 index 0000000000..4a68ccd088 --- /dev/null +++ b/awx/conf/migrations/0004_v320_reencrypt.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations +from awx.conf.migrations import _reencrypt + + +class Migration(migrations.Migration): + + dependencies = [ + ('conf', '0003_v310_JSONField_changes'), + ] + + operations = [ + migrations.RunPython(_reencrypt.replace_aesecb_fernet), + ] diff --git a/awx/conf/migrations/_reencrypt.py b/awx/conf/migrations/_reencrypt.py new file mode 100644 index 0000000000..8a4879dc0e --- /dev/null +++ b/awx/conf/migrations/_reencrypt.py @@ -0,0 +1,110 @@ +import base64 +import hashlib + +import six +from django.utils.encoding import smart_str +from Crypto.Cipher import AES + +from awx.conf.settings import get_settings_to_cache +from awx.conf import settings_registry + + +__all__ = ['replace_aesecb_fernet', 'get_encryption_key', 'encrypt_field', + 'decrypt_value', 'decrypt_value', 'decrypt_field_value'] + + +def replace_aesecb_fernet(apps, schema_editor): + Setting = apps.get_model('conf', 'Setting') + settings_to_cache = get_settings_to_cache(settings_registry) + + for setting in Setting.objects.filter(key__in=settings_to_cache.keys(), user__isnull=True).order_by('pk'): + if settings_registry.is_setting_encrypted(setting.key): + try: + setting.value = decrypt_field(setting, 'value') + setting.save() + except ValueError: + continue + + +def get_encryption_key(field_name, pk=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 ``awx.conf.model.Setting``; + 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.sha1() + h.update(settings.SECRET_KEY) + if pk is not None: + h.update(str(pk)) + h.update(field_name) + return h.digest()[:16] + + +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 != 'AES': + raise ValueError('unsupported algorithm: %s' % algo) + encrypted = base64.b64decode(b64data) + cipher = AES.new(encryption_key, AES.MODE_ECB) + value = cipher.decrypt(encrypted) + value = value.rstrip('\x00') + # If the encrypted string contained a UTF8 marker, decode the data + if utf8: + value = value.decode('utf-8') + return value + + +def decrypt_field(instance, field_name, subfield=None): + ''' + Return content of the given instance and field name decrypted. + ''' + value = getattr(instance, field_name) + if isinstance(value, dict) and subfield is not None: + value = value[subfield] + if not value or not value.startswith('$encrypted$'): + return value + key = get_encryption_key(field_name, getattr(instance, 'pk', None)) + + return decrypt_value(key, value) + + +def decrypt_field_value(pk, field_name, value): + key = get_encryption_key(field_name, pk) + return decrypt_value(key, value) + + +def encrypt_field(instance, field_name, ask=False, subfield=None, skip_utf8=False): + ''' + Return content of the given instance and field name encrypted. + ''' + value = getattr(instance, field_name) + if isinstance(value, dict) and subfield is not None: + value = value[subfield] + if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'): + return value + if skip_utf8: + utf8 = False + else: + utf8 = type(value) == six.text_type + value = smart_str(value) + key = get_encryption_key(field_name, getattr(instance, 'pk', None)) + cipher = AES.new(key, AES.MODE_ECB) + while len(value) % cipher.block_size != 0: + value += '\x00' + encrypted = cipher.encrypt(value) + b64data = base64.b64encode(encrypted) + tokens = ['$encrypted', 'AES', b64data] + if utf8: + # If the value to encrypt is utf-8, we need to add a marker so we + # know to decode the data when it's decrypted later + tokens.insert(1, 'UTF8') + return '$'.join(tokens) diff --git a/awx/conf/tests/functional/test_reencrypt_migration.py b/awx/conf/tests/functional/test_reencrypt_migration.py new file mode 100644 index 0000000000..394a7a8499 --- /dev/null +++ b/awx/conf/tests/functional/test_reencrypt_migration.py @@ -0,0 +1,25 @@ +import pytest +import mock + +from django.apps import apps +from awx.conf.migrations._reencrypt import ( + replace_aesecb_fernet, + encrypt_field, + decrypt_field, +) +from awx.conf.settings import Setting +from awx.main.utils import decrypt_field as new_decrypt_field + + +@pytest.mark.django_db +def test_settings(): + with mock.patch('awx.conf.models.encrypt_field', encrypt_field): + with mock.patch('awx.conf.settings.decrypt_field', decrypt_field): + setting = Setting.objects.create(key='SOCIAL_AUTH_GITHUB_SECRET', value='test') + assert setting.value.startswith('$encrypted$AES$') + + replace_aesecb_fernet(apps, None) + setting.refresh_from_db() + + assert setting.value.startswith('$encrypted$AESCBC$') + assert new_decrypt_field(setting, 'value') == 'test' diff --git a/awx/main/migrations/0038_v320_release.py b/awx/main/migrations/0038_v320_release.py index 38df95682f..04c9a08806 100644 --- a/awx/main/migrations/0038_v320_release.py +++ b/awx/main/migrations/0038_v320_release.py @@ -9,6 +9,7 @@ from psycopg2.extensions import AsIs from django.db import migrations, models # AWX +from awx.main.migrations import _reencrypt as reencrypt import awx.main.fields from awx.main.models import Host @@ -260,7 +261,7 @@ class Migration(migrations.Migration): name='Permission', ), - # Insights + # Insights migrations.AddField( model_name='host', name='insights_system_id', @@ -276,4 +277,5 @@ class Migration(migrations.Migration): name='kind', field=models.CharField(default=b'', help_text='Kind of inventory being represented.', max_length=32, blank=True, choices=[(b'', 'Hosts have a direct link to this inventory.'), (b'smart', 'Hosts for inventory generated using the host_filter property.')]), ), + migrations.RunPython(reencrypt.replace_aesecb_fernet), ] diff --git a/awx/main/migrations/0044_v320_reencrypt.py b/awx/main/migrations/0044_v320_reencrypt.py new file mode 100644 index 0000000000..dc72811b71 --- /dev/null +++ b/awx/main/migrations/0044_v320_reencrypt.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations +from awx.main.migrations import _reencrypt + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0043_v320_instancegroups'), + ] + + operations = [ + migrations.RunPython(_reencrypt.replace_aesecb_fernet), + ] diff --git a/awx/main/migrations/_reencrypt.py b/awx/main/migrations/_reencrypt.py new file mode 100644 index 0000000000..d115c02943 --- /dev/null +++ b/awx/main/migrations/_reencrypt.py @@ -0,0 +1,35 @@ +from awx.conf.migrations._reencrypt import decrypt_field + + +__all__ = ['replace_aesecb_fernet'] + + +def replace_aesecb_fernet(apps, schema_editor): + _notification_templates(apps) + _credentials(apps) + + +def _notification_templates(apps): + NotificationTemplate = apps.get_model('main', 'NotificationTemplate') + for nt in NotificationTemplate.objects.all(): + for field in filter(lambda x: nt.notification_class.init_parameters[x]['type'] == "password", + nt.notification_class.init_parameters): + try: + value = decrypt_field(nt, 'notification_configuration', subfield=field) + nt.notification_configuration[field] = value + except ValueError: + continue + nt.save() + + +def _credentials(apps): + Credential = apps.get_model('main', 'Credential') + for credential in Credential.objects.all(): + for field_name, value in credential.inputs.items(): + if field_name in credential.credential_type.secret_fields: + try: + value = decrypt_field(credential, field_name) + credential.inputs[field_name] = value + except ValueError: + continue + credential.save() diff --git a/awx/main/tests/functional/test_reencrypt_migration.py b/awx/main/tests/functional/test_reencrypt_migration.py new file mode 100644 index 0000000000..96cdd49f07 --- /dev/null +++ b/awx/main/tests/functional/test_reencrypt_migration.py @@ -0,0 +1,48 @@ +import pytest +import mock + +from django.apps import apps + +from awx.main.models import ( + NotificationTemplate, + Credential, +) +from awx.main.models.credential import ssh + +from awx.conf.migrations._reencrypt import encrypt_field +from awx.main.migrations._reencrypt import ( + _notification_templates, + _credentials, +) + +from awx.main.utils import decrypt_field + + +@pytest.mark.django_db +def test_notification_template_migration(): + with mock.patch('awx.main.models.notifications.encrypt_field', encrypt_field): + nt = NotificationTemplate.objects.create(notification_type='slack', notification_configuration=dict(token='test')) + + + assert nt.notification_configuration['token'].startswith('$encrypted$AES$') + + _notification_templates(apps) + nt.refresh_from_db() + + assert nt.notification_configuration['token'].startswith('$encrypted$AESCBC$') + assert decrypt_field(nt, 'notification_configuration', subfield='token') == 'test' + + +@pytest.mark.django_db +def test_credential_migration(): + with mock.patch('awx.main.models.credential.encrypt_field', encrypt_field): + cred_type = ssh() + cred_type.save() + + cred = Credential.objects.create(credential_type=cred_type, inputs=dict(password='test')) + + _credentials(apps) + cred.refresh_from_db() + + assert cred.password.startswith('$encrypted$AESCBC$') + assert decrypt_field(cred, 'password') == 'test'