From d939f95b4fa6be8fc17a7d6ca63dc4eddc7ec2d6 Mon Sep 17 00:00:00 2001 From: Erik van Widenfelt Date: Sat, 16 Mar 2024 23:57:41 -0500 Subject: [PATCH 01/25] Update README.rst --- README.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index b081dac..0d1f625 100644 --- a/README.rst +++ b/README.rst @@ -3,10 +3,14 @@ django-crypto-fields -------------------- -Python 3.8, 3.9, 3.10, Django 3.2, 4.0, 4.1 using mysql +version <= 0.3.8: + Python 3.8, 3.9, 3.10 Django 3.2, 4.0, 4.1 using mysql + +version >= 0.3.8: + Python 3.11+ Django 4.2+ using mysql * Uses ``pycryptodomex`` -* This module has known problems with `postgres`. +* This module has known problems with `postgres`. (I hope to address this soon) Add encrypted field classes to your Django models where ``unique=True`` and ``unique_together`` attributes work as expected. @@ -45,13 +49,13 @@ add to INSTALLED_APPS: ... ) -Add KEY_PATH to the folder in settings: +Add DJANGO_CRYPTO_FIELDS_KEY_PATH to the folder in settings: .. code-block:: python # folder where the encryption keys are stored # Do not set for tests - KEY_PATH = '/etc/myproject/django_crypto_fields') + DJANGO_CRYPTO_FIELDS_KEY_PATH = '/etc/myproject/django_crypto_fields') Add KEY_PREFIX (optional, the default is "user"): @@ -72,7 +76,9 @@ Encryption keys Take care of the encryption keys! -In your tests you can set ``settings.DEBUG = True`` and ``settings.AUTO_CREATE_KEYS = True`` so that keys are generated for your tests. Encryption keys to will not automatically generate on a production system (``DEBUG=False``). See ``AppConfig.auto_create_keys``. +In your tests you can set ``settings.DEBUG = True`` and ``settings.AUTO_CREATE_KEYS = True`` so that keys are generated for your tests. Encryption keys will not automatically generate on a production system (``DEBUG=False``) unless ``settings.AUTO_CREATE_KEYS = True``. + +By default assumes your test module is ``runtests.py``. You can changes this by setting ``settings.DJANGO_CRYPTO_FIELDS_TEST_MODULE``. History ======= From b8ad3101d47f26867f13e8190fcb746369cd9ce5 Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 00:06:51 -0500 Subject: [PATCH 02/25] refactor Keys focusing on paths and file handling --- CHANGES | 14 + django_crypto_fields/__init__.py | 32 +- django_crypto_fields/admin.py | 10 +- django_crypto_fields/apps.py | 101 +----- django_crypto_fields/cryptor.py | 9 +- django_crypto_fields/exceptions.py | 34 +- django_crypto_fields/field_cryptor.py | 25 +- django_crypto_fields/fields/base_aes_field.py | 2 +- django_crypto_fields/fields/base_field.py | 11 +- django_crypto_fields/key_creator.py | 90 ----- django_crypto_fields/key_files.py | 96 ------ django_crypto_fields/key_path.py | 80 ----- django_crypto_fields/key_path/__init__.py | 5 + .../key_path/get_last_key_path.py | 26 ++ django_crypto_fields/key_path/key_path.py | 61 ++++ .../key_path/persist_key_path_or_raise.py | 54 +++ django_crypto_fields/keys.py | 323 ++++++++++++++---- django_crypto_fields/mask_encrypted.py | 2 +- django_crypto_fields/models.py | 6 +- django_crypto_fields/persist_key_path.py | 74 ---- django_crypto_fields/system_checks.py | 166 +++++---- .../tests/etc/user-aes-local.key | 2 - .../tests/etc/user-aes-restricted.key | Bin 256 -> 0 bytes .../tests/etc/user-rsa-local-private.pem | 27 -- .../tests/etc/user-rsa-local-public.pem | 9 - .../tests/etc/user-rsa-restricted-private.pem | 27 -- .../tests/etc/user-rsa-restricted-public.pem | 9 - .../tests/etc/user-salt-local.key | 4 - .../tests/etc/user-salt-restricted.key | Bin 256 -> 0 bytes .../tests/test_key_creator.py | 141 -------- django_crypto_fields/tests/test_settings.py | 30 ++ django_crypto_fields/tests/tests/__init__.py | 0 .../tests/{ => tests}/test_cryptor.py | 27 +- .../tests/{ => tests}/test_field_cryptor.py | 28 +- django_crypto_fields/tests/tests/test_keys.py | 124 +++++++ .../tests/{ => tests}/test_models.py | 16 +- django_crypto_fields/utils.py | 62 +++- pyproject.toml | 4 +- runtests.py | 46 +-- 39 files changed, 855 insertions(+), 922 deletions(-) delete mode 100644 django_crypto_fields/key_creator.py delete mode 100644 django_crypto_fields/key_files.py delete mode 100644 django_crypto_fields/key_path.py create mode 100644 django_crypto_fields/key_path/__init__.py create mode 100644 django_crypto_fields/key_path/get_last_key_path.py create mode 100644 django_crypto_fields/key_path/key_path.py create mode 100644 django_crypto_fields/key_path/persist_key_path_or_raise.py delete mode 100644 django_crypto_fields/persist_key_path.py delete mode 100644 django_crypto_fields/tests/etc/user-aes-local.key delete mode 100644 django_crypto_fields/tests/etc/user-aes-restricted.key delete mode 100644 django_crypto_fields/tests/etc/user-rsa-local-private.pem delete mode 100644 django_crypto_fields/tests/etc/user-rsa-local-public.pem delete mode 100644 django_crypto_fields/tests/etc/user-rsa-restricted-private.pem delete mode 100644 django_crypto_fields/tests/etc/user-rsa-restricted-public.pem delete mode 100644 django_crypto_fields/tests/etc/user-salt-local.key delete mode 100644 django_crypto_fields/tests/etc/user-salt-restricted.key delete mode 100644 django_crypto_fields/tests/test_key_creator.py create mode 100644 django_crypto_fields/tests/test_settings.py create mode 100644 django_crypto_fields/tests/tests/__init__.py rename django_crypto_fields/tests/{ => tests}/test_cryptor.py (78%) rename django_crypto_fields/tests/{ => tests}/test_field_cryptor.py (93%) create mode 100644 django_crypto_fields/tests/tests/test_keys.py rename django_crypto_fields/tests/{ => tests}/test_models.py (89%) diff --git a/CHANGES b/CHANGES index c7460b6..858c4ba 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,19 @@ CHANGES +0.4.0 +----- +- merge functionality of key_creator and key_files into keys module, simplify + and refactor. +- refactor KeyPath +- initialize / create encryption_keys in constructor of Keys class. +- set Keys instance in keys module and import from there instead of + from AppConfig. +- name global Keys instance 'encryption_keys'. +- change settings.KEY_PATH to settings.DJANGO_CRYPTO_FIELDS_KEY_PATH. + (settings.KEY_PATH will still work) +- use pathlib instead of os +- drop support for py3.8, 3.9, 3.10 and Django 3.2, 4.0, 4.1 + 0.3.10 ------ - update testing matrix to include DJ50. Drop DJ41. diff --git a/django_crypto_fields/__init__.py b/django_crypto_fields/__init__.py index 581a993..cf9016a 100644 --- a/django_crypto_fields/__init__.py +++ b/django_crypto_fields/__init__.py @@ -1,25 +1,7 @@ -from django.apps import apps as django_apps -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - - -def get_crypt_model(): - """ - Return the Crypt model that is active in this project. - """ - try: - DJANGO_CRYPTO_FIELDS_MODEL = settings.DJANGO_CRYPTO_FIELDS_MODEL - except AttributeError: - DJANGO_CRYPTO_FIELDS_MODEL = "django_crypto_fields.crypt" - - try: - return django_apps.get_model(DJANGO_CRYPTO_FIELDS_MODEL, require_ready=False) - except ValueError: - raise ImproperlyConfigured( - "DJANGO_CRYPTO_FIELDS_MODEL must be of the form 'app_label.model_name'" - ) - except LookupError: - raise ImproperlyConfigured( - f"DJANGO_CRYPTO_FIELDS_MODEL refers to model {DJANGO_CRYPTO_FIELDS_MODEL} " - "that has not been installed" - ) +# import sys +# +# from .keys import encryption_keys +# +# sys.stdout.write(f"Loading encryption keys ...\n") +# encryption_keys.initialize() +# sys.stdout.write(f"Done loading encryption keys.\n") diff --git a/django_crypto_fields/admin.py b/django_crypto_fields/admin.py index 25b4e00..734df2b 100644 --- a/django_crypto_fields/admin.py +++ b/django_crypto_fields/admin.py @@ -1,18 +1,16 @@ from django.contrib import admin -from . import get_crypt_model from .admin_site import encryption_admin +from .utils import get_crypt_model_cls -Crypt = get_crypt_model() - -@admin.register(Crypt, site=encryption_admin) +@admin.register(get_crypt_model_cls(), site=encryption_admin) class CryptModelAdmin(admin.ModelAdmin): date_hierarchy = "modified" - fields = sorted(tuple(field.name for field in Crypt._meta.fields)) + fields = sorted(tuple(field.name for field in get_crypt_model_cls()._meta.fields)) - readonly_fields = tuple(field.name for field in Crypt._meta.fields) + readonly_fields = tuple(field.name for field in get_crypt_model_cls()._meta.fields) list_display = ("algorithm", "hash", "modified", "hostname_modified") diff --git a/django_crypto_fields/apps.py b/django_crypto_fields/apps.py index a3c6681..0c26e66 100644 --- a/django_crypto_fields/apps.py +++ b/django_crypto_fields/apps.py @@ -1,18 +1,10 @@ import os import sys -from tempfile import mkdtemp from django.apps import AppConfig as DjangoAppConfig -from django.conf import settings -from django.core.checks import register from django.core.management.color import color_style -from .key_creator import KeyCreator -from .key_files import KeyFiles -from .key_path import KeyPath -from .keys import Keys -from .persist_key_path import get_last_key_path -from .system_checks import aes_mode_check, encryption_keys_check, key_path_check +from django_crypto_fields.key_path import KeyPath class DjangoCryptoFieldsError(Exception): @@ -23,91 +15,22 @@ class DjangoCryptoFieldsKeysDoNotExist(Exception): pass -style = color_style() - - class AppConfig(DjangoAppConfig): name: str = "django_crypto_fields" - verbose_name: str = "Data Encryption" - _keys = None - _key_path_validated = None + verbose_name: str = "django-crypto-fields" app_label: str = "django_crypto_fields" - last_key_path_filename: str = "django_crypto_fields" - key_reference_model: str = "django_crypto_fields.keyreference" - # change if using more than one database and not 'default'. crypt_model_using: str = "default" - def __init__(self, app_label: str, model_name: str): - """Placed here instead of `ready()`. For models to - load correctly that use field classes from this module the keys - need to be loaded before models. - """ - self.temp_path = mkdtemp() - - path = None - if getattr(settings, "DJANGO_CRYPTO_FIELDS_TEMP_PATH", "test" in sys.argv): - path = self.temp_path - - self._key_path = KeyPath(path=path) - self.key_files = None - self.last_key_path = get_last_key_path(self.last_key_path_filename) - - sys.stdout.write(f"Loading {self.verbose_name} (init)...\n") - - self.key_files = KeyFiles(key_path=self.key_path) - if not self._keys and not self.key_files.key_files_exist: - if self.auto_create_keys: - if not os.access(self.key_path.path, os.W_OK): - raise DjangoCryptoFieldsError( - "Cannot auto-create encryption keys. Folder is not writeable." - f"Got {self.key_path}" - ) - sys.stdout.write( - style.SUCCESS(f" * settings.AUTO_CREATE_KEYS={self.auto_create_keys}.\n") - ) - key_creator = KeyCreator(key_files=self.key_files, verbose_mode=True) - key_creator.create_keys() - self._keys = Keys(key_path=self.key_path) - self._keys.load_keys() - else: - raise DjangoCryptoFieldsKeysDoNotExist( - f"Failed to find any encryption keys in path {self.key_path}. " - "If this is your first time loading " - "the project, set settings.AUTO_CREATE_KEYS=True and restart. " - "Make sure the folder is writeable." - ) - else: - self._keys = Keys(key_path=self.key_path) - self._keys.load_keys() - - super().__init__(app_label, model_name) - sys.stdout.write(f" Done loading {self.verbose_name} (init)...\n") - def ready(self): + style = color_style() + path = KeyPath().path sys.stdout.write(f"Loading {self.verbose_name} ...\n") - if "test" not in sys.argv: - register(key_path_check)(["django_crypto_fields"]) - register(encryption_keys_check)(["django_crypto_fields"]) - register(aes_mode_check) - sys.stdout.write(f" * found encryption keys in {self.key_path}.\n") - sys.stdout.write(f" * using model {self.app_label}.crypt.\n") + sys.stdout.write(f" * Keys are in folder {path}\n") + if os.access(path, os.W_OK): + sys.stdout.write( + style.WARNING(" * Remember to make folder READ-ONLY in production\n") + ) + sys.stdout.write( + style.WARNING(" * Remember to keep a backup of your encryption keys\n") + ) sys.stdout.write(f" Done loading {self.verbose_name}.\n") - - @property - def encryption_keys(self): - return self._keys - - @property - def auto_create_keys(self): - try: - auto_create_keys = settings.AUTO_CREATE_KEYS - except AttributeError: - auto_create_keys = None - if "test" in sys.argv: - if auto_create_keys is None: - auto_create_keys = True - return auto_create_keys - - @property - def key_path(self): - return self._key_path diff --git a/django_crypto_fields/cryptor.py b/django_crypto_fields/cryptor.py index f55d58c..5d31a96 100644 --- a/django_crypto_fields/cryptor.py +++ b/django_crypto_fields/cryptor.py @@ -2,12 +2,13 @@ from Cryptodome import Random from Cryptodome.Cipher import AES as AES_CIPHER -from django.apps import apps as django_apps from django.conf import settings from django.core.exceptions import AppRegistryNotReady from .constants import AES, ENCODING, PRIVATE, PUBLIC, RSA from .exceptions import EncryptionError +from .keys import encryption_keys +from .utils import get_keypath_from_settings class Cryptor(object): @@ -28,7 +29,7 @@ def __init__(self, keys=None, aes_encryption_mode=None): self.aes_encryption_mode = AES_CIPHER.MODE_CBC try: # ignore "keys" parameter if Django is loaded - self.keys = django_apps.get_app_config("django_crypto_fields").encryption_keys + self.keys = encryption_keys except AppRegistryNotReady: self.keys = keys @@ -106,5 +107,7 @@ def rsa_decrypt(self, ciphertext, mode): try: plaintext = getattr(self.keys, rsa_key).decrypt(ciphertext) except ValueError as e: - raise EncryptionError(f"{e} Using {rsa_key} from key_path=`{settings.KEY_PATH}`.") + raise EncryptionError( + f"{e} Using {rsa_key} from key_path=`{get_keypath_from_settings()}`." + ) return plaintext.decode(ENCODING) diff --git a/django_crypto_fields/exceptions.py b/django_crypto_fields/exceptions.py index 09d11e4..3a51e99 100644 --- a/django_crypto_fields/exceptions.py +++ b/django_crypto_fields/exceptions.py @@ -1,4 +1,8 @@ -class DjangoCryptoFieldsLoadingError(Exception): +class DjangoCryptoFieldsKeyError(Exception): + pass + + +class DjangoCryptoFieldsKeyAlreadyExist(Exception): pass @@ -6,6 +10,34 @@ class DjangoCryptoFieldsKeysAlreadyLoaded(Exception): pass +class DjangoCryptoFieldsKeysNotLoaded(Exception): + pass + + +class DjangoCryptoFieldsError(Exception): + pass + + +class DjangoCryptoFieldsKeysDoNotExist(Exception): + pass + + +class DjangoCryptoFieldsLoadingError(Exception): + pass + + +class DjangoCryptoFieldsKeyPathError(Exception): + pass + + +class DjangoCryptoFieldsKeyPathChangeError(Exception): + pass + + +class DjangoCryptoFieldsKeyPathDoesNotExist(Exception): + pass + + class EncryptionError(Exception): pass diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index c10a7c7..fe4308c 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -4,9 +4,8 @@ from Cryptodome.Cipher import AES as AES_CIPHER from django.apps import apps as django_apps from django.conf import settings -from django.core.exceptions import AppRegistryNotReady, ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist -from . import get_crypt_model from .constants import ( AES, CIPHER_PREFIX, @@ -25,6 +24,8 @@ EncryptionKeyError, MalformedCiphertextError, ) +from .keys import encryption_keys +from .utils import get_crypt_model_cls class FieldCryptor(object): @@ -54,12 +55,7 @@ def __init__(self, algorithm, mode, keys=None, aes_encryption_mode=None): self.aes_encryption_mode = settings.AES_ENCRYPTION_MODE except AttributeError: self.aes_encryption_mode = AES_CIPHER.MODE_CBC - app_config = django_apps.get_app_config("django_crypto_fields") - try: - # ignore "keys" parameter if Django is loaded - self.keys = app_config.encryption_keys - except AppRegistryNotReady: - self.keys = keys + self.keys = encryption_keys self.cryptor = Cryptor(aes_encryption_mode=self.aes_encryption_mode) self.hash_size = len(self.hash("Foo")) @@ -71,7 +67,7 @@ def crypt_model_cls(self): """Returns the cipher model and avoids issues with model loading and field classes. """ - return get_crypt_model() + return get_crypt_model_cls() def hash(self, plaintext): """Returns a hexified hash of a plaintext value (as bytes). @@ -210,11 +206,12 @@ def verify_ciphertext(self, ciphertext): """ try: ciphertext.split(HASH_PREFIX.encode(ENCODING))[1] + except IndexError: + ValueError(f"Malformed ciphertext. Expected prefixes {HASH_PREFIX}") + try: ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] except IndexError: - ValueError( - f"Malformed ciphertext. Expected prefixes " f"{HASH_PREFIX}, {CIPHER_PREFIX}" - ) + ValueError(f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}") try: if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): raise MalformedCiphertextError( @@ -262,8 +259,10 @@ def get_secret(self, ciphertext): """Returns the secret given a ciphertext.""" if ciphertext is None: secret = None - if self.is_encrypted(ciphertext): + elif self.is_encrypted(ciphertext): secret = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] + else: + raise CipherError("Expected a ciphertext or None") return secret def fetch_secret(self, hash_with_prefix): diff --git a/django_crypto_fields/fields/base_aes_field.py b/django_crypto_fields/fields/base_aes_field.py index 5a9ecee..d83d03a 100644 --- a/django_crypto_fields/fields/base_aes_field.py +++ b/django_crypto_fields/fields/base_aes_field.py @@ -6,4 +6,4 @@ class BaseAesField(BaseField): def __init__(self, *args, **kwargs): algorithm = AES mode = LOCAL_MODE - super(BaseAesField, self).__init__(algorithm, mode, *args, **kwargs) + super().__init__(algorithm, mode, *args, **kwargs) diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index 7452295..f05f7d2 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -1,6 +1,5 @@ import sys -from django.apps import apps as django_apps from django.conf import settings from django.core.management.color import color_style from django.db import models @@ -9,11 +8,13 @@ from ..constants import ENCODING, HASH_PREFIX, LOCAL_MODE, RSA from ..exceptions import ( CipherError, + DjangoCryptoFieldsKeysNotLoaded, EncryptionError, EncryptionLookupError, MalformedCiphertextError, ) from ..field_cryptor import FieldCryptor +from ..keys import encryption_keys style = color_style() @@ -22,7 +23,11 @@ class BaseField(models.Field): description = "Field class that stores values as encrypted" def __init__(self, algorithm, mode, *args, **kwargs): - self.keys = django_apps.get_app_config("django_crypto_fields").encryption_keys + self.keys = encryption_keys + if not encryption_keys.loaded: + raise DjangoCryptoFieldsKeysNotLoaded( + "Encryption keys not loaded. You need to run initialize().x" + ) self.algorithm = algorithm or RSA self.mode = mode or LOCAL_MODE self.help_text = kwargs.get("help_text", "") @@ -46,7 +51,7 @@ def __init__(self, algorithm, mode, *args, **kwargs): kwargs["max_length"] = self.max_length kwargs["help_text"] = self.help_text kwargs.setdefault("blank", True) - super(BaseField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def deconstruct(self): name, path, args, kwargs = super(BaseField, self).deconstruct() diff --git a/django_crypto_fields/key_creator.py b/django_crypto_fields/key_creator.py deleted file mode 100644 index 1d607ff..0000000 --- a/django_crypto_fields/key_creator.py +++ /dev/null @@ -1,90 +0,0 @@ -import sys - -from Cryptodome import Random -from Cryptodome.Cipher import PKCS1_OAEP -from Cryptodome.PublicKey import RSA as RSA_PUBLIC_KEY -from django.core.management.color import color_style - -from .constants import AES, PRIVATE, PUBLIC, RSA, RSA_KEY_SIZE, SALT - -style = color_style() - - -class DjangoCryptoFieldsKeyError(Exception): - pass - - -class DjangoCryptoFieldsKeyAlreadyExist(Exception): - pass - - -class KeyCreator: - """Creates new keys if key do not yet exist.""" - - def __init__(self, key_files=None, verbose_mode=None): - self.verbose = verbose_mode - self.key_files = key_files - self.key_path = key_files.key_path - self.key_filenames = key_files.key_filenames - - def create_keys(self): - """Generates RSA and AES keys as per `key_filenames`.""" - if self.key_files.key_files_exist: - raise DjangoCryptoFieldsKeyAlreadyExist( - f"Not creating new keys. Encryption keys already exist. See {self.key_path}." - ) - sys.stdout.write(style.WARNING(" * Generating new encryption keys ...\n")) - self._create_rsa() - self._create_aes() - self._create_salt() - sys.stdout.write(" Done generating new encryption keys.\n") - sys.stdout.write(f" Your new encryption keys are in {self.key_path}.\n") - sys.stdout.write(style.ERROR(" DON'T FORGET TO BACKUP YOUR NEW KEYS!!\n")) - - def _create_rsa(self, mode=None): - """Creates RSA keys.""" - modes = [mode] if mode else self.key_filenames.get(RSA) - for mode in modes: - key = RSA_PUBLIC_KEY.generate(RSA_KEY_SIZE) - pub = key.publickey() - path = self.key_filenames.get(RSA).get(mode).get(PUBLIC) - try: - with open(path, "xb") as fpub: - fpub.write(pub.exportKey("PEM")) - if self.verbose: - sys.stdout.write(f" - Created new RSA {mode} key {path}\n") - path = self.key_filenames.get(RSA).get(mode).get(PRIVATE) - with open(path, "xb") as fpub: - fpub.write(key.exportKey("PEM")) - if self.verbose: - sys.stdout.write(f" - Created new RSA {mode} key {path}\n") - except FileExistsError as e: - raise DjangoCryptoFieldsKeyError(f"RSA key already exists. Got {e}") - - def _create_aes(self, mode=None): - """Creates AES keys and RSA encrypts them.""" - modes = [mode] if mode else self.key_filenames.get(AES) - for mode in modes: - with open(self.key_filenames.get(RSA).get(mode).get(PUBLIC), "rb") as rsa_file: - rsa_key = RSA_PUBLIC_KEY.importKey(rsa_file.read()) - rsa_key = PKCS1_OAEP.new(rsa_key) - aes_key = Random.new().read(16) - key_file = self.key_filenames.get(AES).get(mode).get(PRIVATE) - with open(key_file, "xb") as faes: - faes.write(rsa_key.encrypt(aes_key)) - if self.verbose: - sys.stdout.write(f" - Created new AES {mode} key {key_file}\n") - - def _create_salt(self, mode=None): - """Creates a salt and RSA encrypts it.""" - modes = [mode] if mode else self.key_filenames.get(SALT) - for mode in modes: - with open(self.key_filenames.get(RSA).get(mode).get(PUBLIC), "rb") as rsa_file: - rsa_key = RSA_PUBLIC_KEY.importKey(rsa_file.read()) - rsa_key = PKCS1_OAEP.new(rsa_key) - salt = Random.new().read(8) - key_file = self.key_filenames.get(SALT).get(mode).get(PRIVATE) - with open(key_file, "xb") as fsalt: - fsalt.write(rsa_key.encrypt(salt)) - if self.verbose: - sys.stdout.write(f" - Created new salt {mode} key {key_file}\n") diff --git a/django_crypto_fields/key_files.py b/django_crypto_fields/key_files.py deleted file mode 100644 index 6e79fc7..0000000 --- a/django_crypto_fields/key_files.py +++ /dev/null @@ -1,96 +0,0 @@ -import os -from typing import Dict - -from .constants import AES, LOCAL_MODE, PRIVATE, PUBLIC, RESTRICTED_MODE, RSA, SALT -from .key_path import KeyPath - - -class KeyFiles: - """KEY_FILENAME names the algorithm (rsa, aes or salt), the mode (local and - restricted) and the paths of the files to be created. - - The default KEY_FILENAME dictionary refers to 8 files. - - 2 RSA local (public, private) - - 2 RSA restricted (public, private) - - 1 AES local (RSA encrypted) - - 1 AES restricted (RSA encrypted) - - 1 salt local (RSA encrypted). - - 1 salt restricted (RSA encrypted). - """ - - def __init__(self, key_path: KeyPath = None): - self.key_path = key_path - - @property - def key_filenames(self) -> Dict[str, Dict[str, Dict[str, str]]]: - return { - RSA: { - RESTRICTED_MODE: { - PUBLIC: os.path.join( - self.key_path.path, - self.key_path.key_prefix + "-rsa-restricted-public.pem", - ), - PRIVATE: os.path.join( - self.key_path.path, - self.key_path.key_prefix + "-rsa-restricted-private.pem", - ), - }, - LOCAL_MODE: { - PUBLIC: os.path.join( - self.key_path.path, - self.key_path.key_prefix + "-rsa-local-public.pem", - ), - PRIVATE: os.path.join( - self.key_path.path, - self.key_path.key_prefix + "-rsa-local-private.pem", - ), - }, - }, - AES: { - LOCAL_MODE: { - PRIVATE: os.path.join( - self.key_path.path, self.key_path.key_prefix + "-aes-local.key" - ) - }, - RESTRICTED_MODE: { - PRIVATE: os.path.join( - self.key_path.path, - self.key_path.key_prefix + "-aes-restricted.key", - ) - }, - }, - SALT: { - LOCAL_MODE: { - PRIVATE: os.path.join( - self.key_path.path, self.key_path.key_prefix + "-salt-local.key" - ) - }, - RESTRICTED_MODE: { - PRIVATE: os.path.join( - self.key_path.path, - self.key_path.key_prefix + "-salt-restricted.key", - ) - }, - }, - } - - @property - def key_files_exist(self): - key_files_exist = True - for group, key_group in self.key_filenames.items(): - for mode, keys in key_group.items(): - for key in keys: - if not os.path.exists(self.key_filenames[group][mode][key]): - key_files_exist = False - break - return key_files_exist - - @property - def files(self): - files = [] - for group, key_group in self.key_filenames.items(): - for mode, keys in key_group.items(): - for key in keys: - if os.path.exists(self.key_filenames[group][mode][key]): - files.append(self.key_filenames[group][mode][key]) - return files diff --git a/django_crypto_fields/key_path.py b/django_crypto_fields/key_path.py deleted file mode 100644 index afdc574..0000000 --- a/django_crypto_fields/key_path.py +++ /dev/null @@ -1,80 +0,0 @@ -import os -from typing import Optional - -from django.conf import settings -from django.core.management.color import color_style - - -class DjangoCryptoFieldsKeyPathError(Exception): - pass - - -class DjangoCryptoFieldsKeyPathDoesNotExist(Exception): - pass - - -class DjangoCryptoFieldsKeyPathChangeError(Exception): - pass - - -style = color_style() - - -def get_key_path(no_warn=None) -> str: - key_path = getattr(settings, "KEY_PATH", None) - if not key_path and not settings.DEBUG: - if not no_warn: - raise DjangoCryptoFieldsKeyPathError( - "Key path not set for DEBUG=False. See Settings.KEY_PATH." - ) - else: - key_path = getattr(settings, "KEY_PATH", f"{os.path.join(settings.BASE_DIR, '.etc')}") - return key_path - - -class KeyPath: - """A class to set/determine the correct key_path. - - if this is called during a test, the value of settings.DEBUG sets - the value of settings.KEY_PATH to a tempdir if not set explicitly. - """ - - default_key_prefix = "user" - - # path for non-production use with runserver - non_production_path = os.path.join(settings.BASE_DIR, "crypto_fields") - - def __init__(self, path: Optional[str] = None, key_prefix: Optional[str] = None): - self.key_prefix = key_prefix or self.default_key_prefix - path = path or get_key_path() - self.path: str = self._is_valid(path) - if not self.path: - raise DjangoCryptoFieldsKeyPathError( - f"Invalid key path. Production systems must explicitly " - f"set a path other than the default non-production path " - f"[DEBUG={settings.DEBUG}, " - f"KEY_PATH=='{self.path}', " - f"settings.KEY_PATH=='{get_key_path(no_warn=True)}', " - f"non-production path == '{self.non_production_path}']. " - ) - if self.path == self.non_production_path: - self.using_test_keys = True - if not self.path: - raise DjangoCryptoFieldsKeyPathError("Cannot determine the key path.") - - def __str__(self): - return self.path - - def _is_valid(self, path: Optional[str]) -> str: - """Returns the path or raises.""" - path = path or "" - if settings.DEBUG is False and path == self.non_production_path: - raise DjangoCryptoFieldsKeyPathError( - f"Invalid key path. Key path may not be the default " - f"non-production path if DEBUG=False. Got {self.non_production_path}" - ) - elif not path or not os.path.exists(path): - raise DjangoCryptoFieldsKeyPathDoesNotExist( - f"Key path does not exist. Got '{path}'" - ) - return path diff --git a/django_crypto_fields/key_path/__init__.py b/django_crypto_fields/key_path/__init__.py new file mode 100644 index 0000000..589bd2f --- /dev/null +++ b/django_crypto_fields/key_path/__init__.py @@ -0,0 +1,5 @@ +from .get_last_key_path import get_last_key_path +from .key_path import KeyPath +from .persist_key_path_or_raise import persist_key_path_or_raise + +__all__ = ["get_last_key_path", "persist_key_path_or_raise", "KeyPath"] diff --git a/django_crypto_fields/key_path/get_last_key_path.py b/django_crypto_fields/key_path/get_last_key_path.py new file mode 100644 index 0000000..2061ce9 --- /dev/null +++ b/django_crypto_fields/key_path/get_last_key_path.py @@ -0,0 +1,26 @@ +import csv +import sys +from pathlib import Path, PurePath + +from .key_path import KeyPath + +__all__ = ["get_last_key_path"] + + +def get_last_key_path(filename: str | PurePath) -> PurePath | None: + """Get last used DJANGO_CRYPTO_FIELDS_KEY_PATH from + django_crypto_fields file in the key_path folder. + """ + last_used_path: PurePath | None = None + path = Path(KeyPath().path / filename) + if path.exists(): + if "runtests.py" in sys.argv: + path.unlink() # delete the file + else: + with path.open(mode="r") as f: + reader = csv.DictReader(f) + for row in reader: + # use first row only + last_used_path = PurePath(row.get("path")) + break + return last_used_path diff --git a/django_crypto_fields/key_path/key_path.py b/django_crypto_fields/key_path/key_path.py new file mode 100644 index 0000000..1a40f53 --- /dev/null +++ b/django_crypto_fields/key_path/key_path.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path, PurePath +from tempfile import mkdtemp + +from django.conf import settings + +from ..exceptions import ( + DjangoCryptoFieldsKeyPathDoesNotExist, + DjangoCryptoFieldsKeyPathError, +) +from ..utils import get_keypath_from_settings, get_test_module_from_settings + +__all__ = ["KeyPath"] + + +@dataclass +class KeyPath: + """A class to set/determine the correct key_path. + + if this is called during a test, the value of `settings.DEBUG` sets + the value of settings.DJANGO_CRYPTO_FIELDS_KEY_PATH to a tempdir + if not set explicitly. + """ + + path: PurePath | None = field(default=None, init=False) + + def __post_init__(self): + path = get_keypath_from_settings() + if not path: + if get_test_module_from_settings() in sys.argv: + path = mkdtemp() + else: + raise DjangoCryptoFieldsKeyPathError( + "Path may not be none. Production or debug systems must explicitly " + "set a valid path to the encryption keys. " + "See settings.DJANGO_CRYPTO_FIELDS_KEY_PATH." + ) + elif not Path(path).exists(): + raise DjangoCryptoFieldsKeyPathDoesNotExist( + "Path to encryption keys does not exist. " + "settings.DJANGO_CRYPTO_FIELDS_KEY_PATH='" + f"{get_keypath_from_settings()}'. " + f"Got '{path}'." + ) + if ( + not settings.DEBUG + and get_test_module_from_settings() not in sys.argv + and str(settings.BASE_DIR) in str(path) + ): + raise DjangoCryptoFieldsKeyPathError( + "Invalid production path. Path cannot be in an app folder. " + "See settings.DJANGO_CRYPTO_FIELDS_KEY_PATH. " + f"Got '{path}'." + ) + self.path = PurePath(path) + + def __str__(self) -> str: + return str(self.path) diff --git a/django_crypto_fields/key_path/persist_key_path_or_raise.py b/django_crypto_fields/key_path/persist_key_path_or_raise.py new file mode 100644 index 0000000..4406738 --- /dev/null +++ b/django_crypto_fields/key_path/persist_key_path_or_raise.py @@ -0,0 +1,54 @@ +import csv +import sys +from datetime import datetime +from pathlib import Path, PurePath +from zoneinfo import ZoneInfo + +from django.core.management import color_style + +from ..exceptions import ( + DjangoCryptoFieldsKeyPathChangeError, + DjangoCryptoFieldsKeyPathError, +) +from .key_path import KeyPath + +__all__ = ["persist_key_path_or_raise"] + + +def persist_key_path_or_raise() -> None: + last_used_path: PurePath | None = None + path: Path = Path(KeyPath().path) + file = Path(path / "django_crypto_fields") + if file.exists(): + if "runtests.py" in sys.argv: + file.unlink() # delete the file + else: + # open file `django_crypto_fields` and read last path + with file.open(mode="r") as f: + reader = csv.DictReader(f) + for row in reader: + # use first row only + last_used_path = PurePath(row.get("path")) + break + if not last_used_path: + # persist the path in file `django_crypto_fields` + with file.open(mode="w") as f: + writer = csv.DictWriter(f, fieldnames=["path", "date"]) + writer.writeheader() + writer.writerow(dict(path=path, date=datetime.now().astimezone(ZoneInfo("UTC")))) + last_used_path = path + else: + if not Path(last_used_path).exists(): + style = color_style() + raise DjangoCryptoFieldsKeyPathError( + style.ERROR(f"Invalid last key path. See {file}. Got {last_used_path}") + ) + if last_used_path != path: + style = color_style() + raise DjangoCryptoFieldsKeyPathChangeError( + style.ERROR( + "Key path changed since last startup! You must resolve " + "this before using the system. Using the wrong keys will " + "corrupt your data." + ) + ) diff --git a/django_crypto_fields/keys.py b/django_crypto_fields/keys.py index d8401a2..71a5662 100644 --- a/django_crypto_fields/keys.py +++ b/django_crypto_fields/keys.py @@ -1,15 +1,36 @@ -import copy +from __future__ import annotations + +import os import sys +from copy import deepcopy +from pathlib import Path, PurePath +from Cryptodome import Random from Cryptodome.Cipher import PKCS1_OAEP from Cryptodome.PublicKey import RSA as RSA_PUBLIC_KEY from Cryptodome.Util import number -from django.apps import apps as django_apps -from django.core.exceptions import AppRegistryNotReady +from django.conf import settings +from django.core.management.color import color_style -from .constants import AES, PRIVATE, RSA, SALT -from .exceptions import DjangoCryptoFieldsKeysAlreadyLoaded -from .key_files import KeyFiles +from .constants import ( + AES, + LOCAL_MODE, + PRIVATE, + PUBLIC, + RESTRICTED_MODE, + RSA, + RSA_KEY_SIZE, + SALT, +) +from .exceptions import ( + DjangoCryptoFieldsError, + DjangoCryptoFieldsKeyAlreadyExist, + DjangoCryptoFieldsKeyError, + DjangoCryptoFieldsKeysAlreadyLoaded, + DjangoCryptoFieldsKeysDoNotExist, +) +from .key_path import KeyPath, persist_key_path_or_raise +from .utils import get_auto_create_keys_from_settings, get_key_prefix_from_settings class Keys: @@ -20,87 +41,257 @@ class Keys: * Keys are create through the AppConfig __init__ method, if necessary. """ - keys_are_ready = False - rsa_key_info = {} - key_files_cls = KeyFiles + rsa_key_info: dict = {} + key_prefix: str = get_key_prefix_from_settings() - def __init__(self, **kwargs): - key_files = self.key_files_cls(**kwargs) - self.key_path: str = key_files.key_path - self.key_filenames = key_files.key_filenames - self._keys = copy.deepcopy(key_files.key_filenames) - self.rsa_modes_supported = sorted([k for k in self._keys[RSA]]) - self.aes_modes_supported = sorted([k for k in self._keys[AES]]) + def __init__(self, verbose: bool = None): + self.keys = None + self.loaded = False + self.verbose = True if verbose is None else verbose + self.rsa_modes_supported = None + self.aes_modes_supported = None + self.files: list[str] = [] + self.path: PurePath = KeyPath().path + self.template: dict[str, dict[str, dict[str, RSA_PUBLIC_KEY]]] = { + RSA: { + RESTRICTED_MODE: { + PUBLIC: self.path / (self.key_prefix + "-rsa-restricted-public.pem"), + PRIVATE: self.path / (self.key_prefix + "-rsa-restricted-private.pem"), + }, + LOCAL_MODE: { + PUBLIC: self.path / (self.key_prefix + "-rsa-local-public.pem"), + PRIVATE: self.path / (self.key_prefix + "-rsa-local-private.pem"), + }, + }, + AES: { + LOCAL_MODE: {PRIVATE: self.path / (self.key_prefix + "-aes-local.key")}, + RESTRICTED_MODE: { + PRIVATE: self.path / (self.key_prefix + "-aes-restricted.key"), + }, + }, + SALT: { + LOCAL_MODE: {PRIVATE: self.path / (self.key_prefix + "-salt-local.key")}, + RESTRICTED_MODE: { + PRIVATE: self.path / (self.key_prefix + "-salt-restricted.key"), + }, + }, + } + for _, v in self.template.items(): + for _, _v in v.items(): + for _, fname in _v.items(): + self.files.append(fname) + self.initialize() - def load_keys(self): - """Loads all keys defined in self.key_filenames.""" - try: - if django_apps.get_app_config("django_crypto_fields").encryption_keys: - raise DjangoCryptoFieldsKeysAlreadyLoaded( - "Encryption keys have already been loaded." + def initialize(self): + """Load keys and create if necessary.""" + style = color_style() + if not getattr(settings, "DJANGO_CRYPTO_FIELDS_INITIALIZE", True): + sys.stdout.write( + style.ERROR( + " * NOT LOADING ENCRYPTION KEYS, see " + "settings.DJANGO_CRYPTO_FIELDS_INITIALIZE\n" ) - except (AppRegistryNotReady, AttributeError): - pass - if not self.keys_are_ready: - sys.stdout.write(f" * loading keys from {self.key_path}\n") - for mode, keys in self.key_filenames[RSA].items(): - for key in keys: - sys.stdout.write(f" * loading {RSA}.{mode}.{key} ...\r") - self.load_rsa_key(mode, key) - sys.stdout.write(f" * loading {RSA}.{mode}.{key} ... Done.\n") - for mode in self.key_filenames[AES]: - sys.stdout.write(f" * loading {AES}.{mode} ...\r") - self.load_aes_key(mode) - sys.stdout.write(f" * loading {AES}.{mode} ... Done.\n") - for mode in self.key_filenames[SALT]: - sys.stdout.write(f" * loading {SALT}.{mode} ...\r") - self.load_salt_key(mode, key) - sys.stdout.write(f" * loading {SALT}.{mode} ... Done.\n") - self.keys_are_ready = True - - def load_rsa_key(self, mode, key): + ) + else: + self.write("Loading encryption keys\n") + self.keys = deepcopy(self.template) + persist_key_path_or_raise() + if not self.key_files_exist: + if auto_create_keys := get_auto_create_keys_from_settings(): + if not os.access(self.path, os.W_OK): + raise DjangoCryptoFieldsError( + "Cannot auto-create encryption keys. Folder is not writeable." + f"Got {self.path}" + ) + self.write( + style.SUCCESS(f" * settings.AUTO_CREATE_KEYS={auto_create_keys}.\n") + ) + self.create() + else: + raise DjangoCryptoFieldsKeysDoNotExist( + f"Failed to find any encryption keys in path {self.path}. " + "If this is your first time loading " + "the project, set settings.AUTO_CREATE_KEYS=True and restart. " + "Make sure the folder is writeable." + ) + self.load_keys() + self.rsa_modes_supported = sorted([k for k in self.keys[RSA]]) + self.aes_modes_supported = sorted([k for k in self.keys[AES]]) + self.write(" Done loading encryption keys\n") + + def reset(self, delete_all_keys: str = None, verbose: bool = None): + """Use with extreme care!""" + verbose = True if verbose is None else verbose + self.keys = deepcopy(self.template) + self.loaded = False + if delete_all_keys == "delete_all_keys": + if verbose: + style = color_style() + sys.stdout.write(style.ERROR(" * Deleting encryption keys\n")) + for file in encryption_keys.files: + try: + Path(file).unlink() + except FileNotFoundError: + pass + + def get(self, k: str): + return self.keys.get(k) + + def create(self) -> None: + """Generates RSA and AES keys as per `filenames`.""" + style = color_style() + if self.key_files_exist: + raise DjangoCryptoFieldsKeyAlreadyExist( + f"Not creating new keys. Encryption keys already exist. See {self.path}." + ) + self.write(style.WARNING(" * Generating new encryption keys ...\n")) + self._create_rsa() + self._create_aes() + self._create_salt() + self.write(" Done generating new encryption keys.\n") + self.write(f" Your new encryption keys are in {self.path}.\n") + self.write(style.ERROR(" DON'T FORGET TO BACKUP YOUR NEW KEYS!!\n")) + + def write(self, msg: str): + if self.verbose: + sys.stdout.write(msg) + + def load_keys(self) -> None: + """Loads all keys defined in self.filenames.""" + if self.loaded: + raise DjangoCryptoFieldsKeysAlreadyLoaded( + f"Encryption keys have already been loaded. Path='{self.path}'." + ) + self.write(f" * loading keys from {self.path}\n") + for mode, keys in self.keys[RSA].items(): + for key in keys: + self.write(f" * loading {RSA}.{mode}.{key} ...\r") + self.load_rsa_key(mode, key) + self.write(f" * loading {RSA}.{mode}.{key} ... Done.\n") + for mode in self.keys[AES]: + self.write(f" * loading {AES}.{mode} ...\r") + self.load_aes_key(mode) + self.write(f" * loading {AES}.{mode} ... Done.\n") + for mode in self.keys[SALT]: + self.write(f" * loading {SALT}.{mode} ...\r") + self.load_salt_key(mode, key) + self.write(f" * loading {SALT}.{mode} ... Done.\n") + self.loaded = True + + def load_rsa_key(self, mode, key) -> None: """Loads an RSA key into _keys.""" - key_file = self.key_filenames[RSA][mode][key] - with open(key_file, "rb") as frsa: - rsa_key = RSA_PUBLIC_KEY.importKey(frsa.read()) + if self.loaded: + raise DjangoCryptoFieldsKeysAlreadyLoaded( + "Encryption keys have already been loaded." + ) + path = Path(self.keys[RSA][mode][key]) + with path.open(mode="rb") as f: + rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) rsa_key = PKCS1_OAEP.new(rsa_key) - self._keys[RSA][mode][key] = rsa_key + self.keys[RSA][mode][key] = rsa_key self.update_rsa_key_info(rsa_key, mode) setattr(self, RSA + "_" + mode + "_" + key + "_key", rsa_key) - return key_file - def load_aes_key(self, mode): + def load_aes_key(self, mode) -> None: """Decrypts and loads an AES key into _keys. Note: AES does not use a public key. """ + if self.loaded: + raise DjangoCryptoFieldsKeysAlreadyLoaded( + "Encryption keys have already been loaded." + ) key = PRIVATE - rsa_key = self._keys[RSA][mode][key] + rsa_key = self.keys[RSA][mode][key] try: - key_file = self.key_filenames[AES][mode][key] + path = Path(self.keys[AES][mode][key]) except KeyError: raise - with open(key_file, "rb") as faes: - aes_key = rsa_key.decrypt(faes.read()) - self._keys[AES][mode][key] = aes_key + with path.open(mode="rb") as f: + aes_key = rsa_key.decrypt(f.read()) + self.keys[AES][mode][key] = aes_key setattr(self, AES + "_" + mode + "_" + key + "_key", aes_key) - return key_file - def load_salt_key(self, mode, key): + def load_salt_key(self, mode, key) -> None: """Decrypts and loads a salt key into _keys.""" + if self.loaded: + raise DjangoCryptoFieldsKeysAlreadyLoaded( + "Encryption keys have already been loaded." + ) attr = SALT + "_" + mode + "_" + PRIVATE - rsa_key = self._keys[RSA][mode][PRIVATE] - key_file = self.key_filenames[SALT][mode][PRIVATE] - with open(key_file, "rb") as fsalt: - salt = rsa_key.decrypt(fsalt.read()) + rsa_key = self.keys[RSA][mode][PRIVATE] + path = Path(self.keys[SALT][mode][PRIVATE]) + with path.open(mode="rb") as f: + salt = rsa_key.decrypt(f.read()) setattr(self, attr, salt) - return key_file - def update_rsa_key_info(self, rsa_key, mode): + def update_rsa_key_info(self, rsa_key, mode) -> None: """Stores info about the RSA key.""" - modBits = number.size(rsa_key._key.n) - self.rsa_key_info[mode] = {"bits": modBits} - k = number.ceil_div(modBits, 8) + if self.loaded: + raise DjangoCryptoFieldsKeysAlreadyLoaded( + "Encryption keys have already been loaded." + ) + mod_bits = number.size(rsa_key._key.n) + self.rsa_key_info[mode] = {"bits": mod_bits} + k = number.ceil_div(mod_bits, 8) self.rsa_key_info[mode].update({"bytes": k}) - hLen = rsa_key._hashObj.digest_size - self.rsa_key_info[mode].update({"max_message_length": k - (2 * hLen) - 2}) + h_len = rsa_key._hashObj.digest_size + self.rsa_key_info[mode].update({"max_message_length": k - (2 * h_len) - 2}) + + def _create_rsa(self, mode=None) -> None: + """Creates RSA keys.""" + modes = [mode] if mode else self.keys.get(RSA) + for mode in modes: + key = RSA_PUBLIC_KEY.generate(RSA_KEY_SIZE) + pub = key.publickey() + path = Path(self.keys.get(RSA).get(mode).get(PUBLIC)) + try: + with path.open(mode="xb") as f1: + f1.write(pub.exportKey("PEM")) + self.write(f" - Created new RSA {mode} key {path}\n") + path = Path(self.keys.get(RSA).get(mode).get(PRIVATE)) + with open(path, "xb") as f2: + f2.write(key.exportKey("PEM")) + self.write(f" - Created new RSA {mode} key {path}\n") + except FileExistsError as e: + raise DjangoCryptoFieldsKeyError(f"RSA key already exists. Got {e}") + + def _create_aes(self, mode=None) -> None: + """Creates AES keys and RSA encrypts them.""" + modes = [mode] if mode else self.keys.get(AES) + for mode in modes: + with Path(self.keys.get(RSA).get(mode).get(PUBLIC)).open(mode="rb") as rsa_file: + rsa_key = RSA_PUBLIC_KEY.importKey(rsa_file.read()) + rsa_key = PKCS1_OAEP.new(rsa_key) + aes_key = Random.new().read(16) + path = Path(self.keys.get(AES).get(mode).get(PRIVATE)) + with path.open(mode="xb") as f: + f.write(rsa_key.encrypt(aes_key)) + self.write(f" - Created new AES {mode} key {path}\n") + + def _create_salt(self, mode=None) -> None: + """Creates a salt and RSA encrypts it.""" + modes = [mode] if mode else self.keys.get(SALT) + for mode in modes: + with Path(self.keys.get(RSA).get(mode).get(PUBLIC)).open(mode="rb") as rsa_file: + rsa_key = RSA_PUBLIC_KEY.importKey(rsa_file.read()) + rsa_key = PKCS1_OAEP.new(rsa_key) + salt = Random.new().read(8) + path = Path(self.keys.get(SALT).get(mode).get(PRIVATE)) + with path.open(mode="xb") as f: + f.write(rsa_key.encrypt(salt)) + self.write(f" - Created new salt {mode} key {path}\n") + + @property + def key_files_exist(self) -> bool: + key_files_exist = False + for group, key_group in self.keys.items(): + for mode, keys in key_group.items(): + for key in keys: + if Path(self.keys[group][mode][key]).exists(): + key_files_exist = True + break + return key_files_exist + + +encryption_keys = Keys() diff --git a/django_crypto_fields/mask_encrypted.py b/django_crypto_fields/mask_encrypted.py index 92b3291..a1aa4d2 100644 --- a/django_crypto_fields/mask_encrypted.py +++ b/django_crypto_fields/mask_encrypted.py @@ -2,5 +2,5 @@ from .field_cryptor import FieldCryptor -def mask_encrypted(value): +def mask_encrypted(value) -> FieldCryptor: return FieldCryptor(RSA, LOCAL_MODE).mask(value) diff --git a/django_crypto_fields/models.py b/django_crypto_fields/models.py index 79af93a..52878b9 100644 --- a/django_crypto_fields/models.py +++ b/django_crypto_fields/models.py @@ -44,7 +44,11 @@ class Crypt(AuditUuidModelMixin, models.Model): objects = CryptModelManager() def natural_key(self): - return (self.hash, self.algorithm, self.mode) + return ( + self.hash, + self.algorithm, + self.mode, + ) class Meta: verbose_name = "Crypt" diff --git a/django_crypto_fields/persist_key_path.py b/django_crypto_fields/persist_key_path.py deleted file mode 100644 index de8ac80..0000000 --- a/django_crypto_fields/persist_key_path.py +++ /dev/null @@ -1,74 +0,0 @@ -import csv -import os -import sys -from datetime import datetime -from zoneinfo import ZoneInfo - -from django.apps import apps as django_apps -from django.conf import settings -from django.core.management.color import color_style - -style = color_style() - - -class DjangoCryptoFieldsKeyPathError(Exception): - pass - - -class DjangoCryptoFieldsKeyPathChangeError(Exception): - pass - - -def get_etc_dir(no_warn=None): - etc_dir = getattr(settings, "ETC_DIR", None) - if not etc_dir and not settings.DEBUG: - if not no_warn: - raise DjangoCryptoFieldsKeyPathError( - "ETC_DIR not set for DEBUG=False. See Settings.ETC_DIR." - ) - else: - etc_dir = getattr(settings, "ETC_DIR", f"{os.path.join(settings.BASE_DIR, '.etc')}") - return etc_dir - - -def get_last_key_path(filename=None): - last_key_path = None - if "test" not in sys.argv: - if not filename: - app_config = django_apps.get_app_config("django_crypto_fields") - filename = app_config.last_key_path_filename - - filepath = os.path.join(get_etc_dir(), filename) - if os.path.exists(filepath): - with open(filepath, "r") as f: - reader = csv.DictReader(f) - for row in reader: - last_key_path = row.get("path") - break - return last_key_path - - -def persist_key_path(key_path=None, filename=None): - last_key_path = get_last_key_path(filename) - - if not last_key_path: - with open(filename, "w") as f: - writer = csv.DictWriter(f, fieldnames=["path", "date"]) - writer.writeheader() - writer.writerow( - dict(path=key_path.path, date=datetime.now().astimezone(ZoneInfo("UTC"))) - ) - last_key_path = key_path.path - else: - if not os.path.exists(last_key_path): - raise DjangoCryptoFieldsKeyPathError( - style.ERROR(f"Invalid last key path. See {filename}. Got {last_key_path}") - ) - if last_key_path != key_path.path: - raise DjangoCryptoFieldsKeyPathChangeError( - style.ERROR( - "Key path changed since last startup! You must resolve " - "this before using the system. Using the wrong keys will " - "corrupt your data." - ) - ) diff --git a/django_crypto_fields/system_checks.py b/django_crypto_fields/system_checks.py index 3732f80..4409ade 100644 --- a/django_crypto_fields/system_checks.py +++ b/django_crypto_fields/system_checks.py @@ -1,86 +1,80 @@ -import os -import sys -from collections import namedtuple - -from Cryptodome.Cipher import AES -from django.apps import apps as django_apps -from django.conf import settings -from django.core.checks import Critical, Error - -from .cryptor import Cryptor -from .persist_key_path import ( - DjangoCryptoFieldsKeyPathChangeError, - DjangoCryptoFieldsKeyPathError, - persist_key_path, -) - -err = namedtuple("Err", "id cls") - -error_configs = dict( - key_path_check=err("django_crypto_fields.C001", Critical), - encryption_keys_check=err("django_crypto_fields.E001", Error), - aes_mode_check=err("django_crypto_fields.E002", Error), -) - - -def testing(): - if "test" in sys.argv: - return True - if "runtests" in sys.argv: - return True - return False - - -def key_path_check(app_configs, **kwargs): - errors = [] - if not settings.DEBUG: - app_config = django_apps.get_app_config("django_crypto_fields") - key_path = app_config.key_path - error = error_configs.get("key_path_check") - check_failed = False - filename = os.path.join(settings.ETC_DIR, "django_crypto_fields") - hint = f"settings.KEY_PATH does not match the path stored in {filename}." - try: - persist_key_path(key_path=key_path, filename=filename) - except ( - DjangoCryptoFieldsKeyPathChangeError, - DjangoCryptoFieldsKeyPathError, - ) as e: - error_msg = str(e) - check_failed = True - if check_failed: - errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) - return errors - - -def encryption_keys_check(app_configs, **kwargs): - app_config = django_apps.get_app_config("django_crypto_fields") - key_files = app_config.key_files - errors = [] - check_failed = None - try: - auto_create_keys = settings.AUTO_CREATE_KEYS - except AttributeError: - auto_create_keys = None - if key_files.key_files_exist and auto_create_keys and not testing(): - error = error_configs.get("encryption_keys_check") - error_msg = "settings.AUTO_CREATE_KEYS may not be 'True' when encryption keys exist." - hint = ( - "Did you backup your keys? Perhaps you just created new keys, " - "to continue, set AUTO_CREATE_KEYS=False and restart." - ) - check_failed = True - if check_failed: - errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) - return errors - - -def aes_mode_check(app_configs, **kwargs): - error = error_configs.get("aes_mode_check") - errors = [] - hint = "See django_crypto_fields.cryptor.py and comments in pycryptodomex.blockalgo.py." - cryptor = Cryptor() - if cryptor.aes_encryption_mode == AES.MODE_CFB: - error_msg = "Encryption mode MODE_CFB should not be used." - errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) - return errors +# import os +# import sys +# from collections import namedtuple +# +# from Cryptodome.Cipher import AES +# from django.apps import apps as django_apps +# from django.conf import settings +# from django.core.checks import Critical, Error +# +# from .cryptor import Cryptor +# from .exceptions import ( +# DjangoCryptoFieldsKeyPathChangeError, +# DjangoCryptoFieldsKeyPathError, +# ) +# from .key_path import KeyPath +# from .keys import encryption_keys +# from .utils import persist_key_path +# +# err = namedtuple("Err", "id cls") +# +# error_configs = dict( +# key_path_check=err("django_crypto_fields.C001", Critical), +# encryption_keys_check=err("django_crypto_fields.E001", Error), +# aes_mode_check=err("django_crypto_fields.E002", Error), +# ) +# +# +# def testing(): +# if "test" in sys.argv: +# return True +# if "runtests" in sys.argv: +# return True +# return False +# +# +# def key_path_check(app_configs, **kwargs): +# errors = [] +# if not settings.DEBUG: +# key_path = KeyPath() +# error = error_configs.get("key_path_check") +# filename = os.path.join(settings.ETC_DIR, "django_crypto_fields") +# hint = f"settings.KEY_PATH does not match the path stored in {filename}." +# try: +# persist_key_path(key_path=key_path, filename=filename) +# except ( +# DjangoCryptoFieldsKeyPathChangeError, +# DjangoCryptoFieldsKeyPathError, +# ) as e: +# error_msg = str(e) +# errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) +# return errors +# +# +# def encryption_keys_check(app_configs, **kwargs): +# app_config = django_apps.get_app_config("django_crypto_fields") +# errors = [] +# try: +# auto_create_keys = settings.AUTO_CREATE_KEYS +# except AttributeError: +# auto_create_keys = None +# if encryption_keys.key_files_exist and auto_create_keys and not testing(): +# error = error_configs.get("encryption_keys_check") +# error_msg = "settings.AUTO_CREATE_KEYS may not be 'True' when encryption keys exist." +# hint = ( +# "Did you backup your keys? Perhaps you just created new keys, " +# "to continue, set AUTO_CREATE_KEYS=False and restart." +# ) +# errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) +# return errors +# +# +# def aes_mode_check(app_configs, **kwargs): +# error = error_configs.get("aes_mode_check") +# errors = [] +# hint = "See django_crypto_fields.cryptor.py and comments in pycryptodomex.blockalgo.py." +# cryptor = Cryptor() +# if cryptor.aes_encryption_mode == AES.MODE_CFB: +# error_msg = "Encryption mode MODE_CFB should not be used." +# errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) +# return errors diff --git a/django_crypto_fields/tests/etc/user-aes-local.key b/django_crypto_fields/tests/etc/user-aes-local.key deleted file mode 100644 index 96e8dc8..0000000 --- a/django_crypto_fields/tests/etc/user-aes-local.key +++ /dev/null @@ -1,2 +0,0 @@ -u]:{~J&7zvtFاfӼh!9&q P 8SiDsf6?k|D>cMzjNU(􁧈^V(lA;w 01@~_n>@^T?DJKSH -XdD9JT {n x/L5%4w擸+B.2BH@PpIm&rILغ8 \ No newline at end of file diff --git a/django_crypto_fields/tests/etc/user-aes-restricted.key b/django_crypto_fields/tests/etc/user-aes-restricted.key deleted file mode 100644 index 155f0b24d987d7bad74ad984dcd8951fe438967c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 256 zcmV+b0ssDSRbzF~{UA|c?zE!&j4TTj*NYT+@y-VMl>7 z9w;7OUymDM-n~PVg)g(j$xt*^mIQGSW>3kLw{~b5D#s1#5zoM6z$Dnuu36|<;?tug z>YiR8piDiuR&Y<5k+xd@pxp3Gm*a{7yT)6BbP^cNdb|8 G{r5nc=YFgJ diff --git a/django_crypto_fields/tests/etc/user-rsa-local-private.pem b/django_crypto_fields/tests/etc/user-rsa-local-private.pem deleted file mode 100644 index c0eedf0..0000000 --- a/django_crypto_fields/tests/etc/user-rsa-local-private.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEogIBAAKCAQEAzuG6pNbkHzNGzRfvTegeDEkvxNC9NW9y5LGaRdpu+j5tdqB3 -65igRYwFLIPlPOsY1sFcVmeFmjXDfaITLNa3nJywWsf9XPCB6/xt0LwBrkM9dT7q -c2gcS8maf3/Vb8MfdMu7IF5g31F/RYmSycx6jW7VAto1RetENzKLAk9UIQzfmv7g -Yh7prxJHoK2zLtd7PT5InqJzoPmtlSkVys7KTExL4NzIgIOgg82KZMOivHLbw5HY -j1lxq0l9hdmreZ6Cz2r0bOP850LfQqAe721Qur2B+thy0B/9Hg0B1QmeqmY0kz/z -uggr0fUE6+RHcOZ6nel3rhsR++magbxsjR7QCwIDAQABAoIBADCiso00E7ceB+QJ -X3HSQtYikn9Tj1ezlrAa7KjFXFeqO3Oq7+ZMIEmZTFIVw9uZbWJ3XKzXc3o4b3fm -lVlyNQI1d+QvpemmhPSkiHCM8lw0ZIteuiFddWF/yLl2pQe8OHXtu1U1utJlR2Vi -nUahsqHzT1J3WBTS5VuQn0twuNd30dLjMyS8lr8IXTf14tR2F7Zw9HpFRM91luVu -qmcRcq8eqODk1bwe/VaMDx8aGsvhljBFz/tIzkP4BIyAa592OxqYBqpDxwuvfQw2 -59RUYcoqj6qEvmBw1p0ATNmVvAuZmFn4Ow80rCp33EbQ01EjKfoDSnvHvmUuZ2K8 -9rftfxECgYEA1lZBvN6+USNDlShYqGKs5PguHBI/dJcv4oOfu7bURyPpLX4jLxdY -o+/HDpKSPGtyNfjWK9OT7wMbMOGW2LRjj6hX6XfB9Jcy4gde7iVfOwhM77q3lwdo -+pcf58naV33wZFoBemWIXr/HlI/6TrBQlPIWcNvvgaFuDylY9wGsWKMCgYEA9xh9 -kOmvdpKzfiy2O0sYjlGMFF5ddjpiBYGI0qnbFE3d5J6t/bAjgHt6JuUZF6hHck1E -Wi84yCXoeJk2Rrv2CNsWi2BdNn8+Ff51R8xccWhCx+bT/tciQ50o2s5HKT42PfY0 -xRMJjoKVRoJYO2pkKgJWpiHfXF0gursyb1ULGXkCgYB1JEtlWC+X1LgZCyX5UYTA -10sMGIUJyZ9oIxvn0fKOtve331qHYDEX1/Jo6n51+xs+mDMlXMtbM81ml8SDx4Mq -fo0dklA0x3YNxo2BhndXoh+6Xcf9CRW871+GpPFqI/CASBjKtjcj4ZjIfzAEVaSU -4wKUx/9MT9gA/U4cIZP6FQKBgHDacp742/af0fLSoPg7uB9oBz5WSwFwcMxq+P6e -LTz8w1djUbwH8m7/9i5FfExdwyPlPk6iOqDPD3nlH/D2b8xjo8xMWsZFHyoUaaQ9 -Jgt1vupl9nTx9OhEoyAsDjw7+bIa/Mb1huvboCnv5jRcxxqYrtJ5rfYVYplmXgaT -JNqhAoGAa9FYDdDr3nF7fVi+dIgYBBhCyppMtj6cLd9wOqUBdrqRsW2gHMsJUGDX -isc2Rwaw18a+2WvcOi2gI1blF9oDFjmPNwYTSDTI6LpPuLObI+kNYaGYM8Hr58jF -JV3IvRyMI4Qq/5RSadraywzwu0TZ3b8dLt0ZC7fgSBKWGhKvivA= ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/django_crypto_fields/tests/etc/user-rsa-local-public.pem b/django_crypto_fields/tests/etc/user-rsa-local-public.pem deleted file mode 100644 index c28749c..0000000 --- a/django_crypto_fields/tests/etc/user-rsa-local-public.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzuG6pNbkHzNGzRfvTege -DEkvxNC9NW9y5LGaRdpu+j5tdqB365igRYwFLIPlPOsY1sFcVmeFmjXDfaITLNa3 -nJywWsf9XPCB6/xt0LwBrkM9dT7qc2gcS8maf3/Vb8MfdMu7IF5g31F/RYmSycx6 -jW7VAto1RetENzKLAk9UIQzfmv7gYh7prxJHoK2zLtd7PT5InqJzoPmtlSkVys7K -TExL4NzIgIOgg82KZMOivHLbw5HYj1lxq0l9hdmreZ6Cz2r0bOP850LfQqAe721Q -ur2B+thy0B/9Hg0B1QmeqmY0kz/zuggr0fUE6+RHcOZ6nel3rhsR++magbxsjR7Q -CwIDAQAB ------END PUBLIC KEY----- \ No newline at end of file diff --git a/django_crypto_fields/tests/etc/user-rsa-restricted-private.pem b/django_crypto_fields/tests/etc/user-rsa-restricted-private.pem deleted file mode 100644 index e61dfb6..0000000 --- a/django_crypto_fields/tests/etc/user-rsa-restricted-private.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAvumZQWfPLe0AHUjV+WJqxGALPBRioyUgA1ezIocls8fQzgb0 -Y7Y/L4n4vu/Bo9jAVSq3pW0wOhlJBCr4MrhcfhOu+xS/TqMP9ArI/PCzYkD+RpSR -vicQZiHg8SliCfuNNGBpB9ZzbSDdsqvCbMR152SxnPYQ6QEMjsZROpP6LlUSUhhh -tTG62Uo/giU+vZKZ1CrKWgWdCl4n5nBSrb51uLlUArsgYyStAaVxMUZ2EL7tAJm6 -smy42K16KkT+RTBKfqfOuaWwMx4IewGQ5FCUNoprE43HiASq7sNeY29v5MFYVMjf -rV3ZpPlsBwr0OEFP/LmMRR3Hy1lBpv8IKm1oDQIDAQABAoIBADe9vtiA9dXj7/ec -75TL7n59yGn3TcFysoEnVw9KxHBUdBvjnfGLIbHAqBcfq7sqKyXyvgIX+k/MWCxW -W3d7qs3I83st2JmeNKc9iueHY+jZLyTudgs+HqSjWakwOwbNIrJbP+9g5vzC7tgf -HDpjtkUkelNo6REPFFqPfuIKmK5z+GQjQjTx6tn6w8MscgtLkXl3bm8WfdKTqu4N -qBEFUtcD67w9B7rojuSKA9pJMW6SaqD9spXY5RW1wCwCC5r+HbIM3v0DvVKMZagw -ZAIFLGD6/mV7N1ljmgWrCNXnq74Jc7neXz90TaC3k53DFRhiz0YDADAP8ohifwQw -iHMUTkUCgYEAxXAbruCBz9u72GQqVH44rR7bq3aDssfRsT1uDZ0SUfIrrSltitM5 -uizVyEWm5e+QR0WwfdB0twS5L0NAFcr+OAMu/Cuvcn7WrHG3QHQz0z/2oBMhgqyM -dEwdWGoPWS45hQuGJuJrXZ3jnkiqMt0rQLjWM0KNCu4iLP6lZLMAbYsCgYEA94oA -JUhABZaqmFMTvisZ6TdInrbq4o2OW0O2IWN+iKwzXNznsWrUD3XS/GK4HdbTanUV -a3JHD/YIJeZ+l4XTSe86yQfN/l2uwpo0+QDMuxnumh+Ijbxnst2bfNSk7S0hJcZE -6hT6JjrzjMkxIRmxViXE5TC/IHoXsGcD1Tai48cCgYB07DZIswXNLaiptm/nB7TS -uTKH8TB/AJyq5OE2yK0fwFWjP6RykTJfkcusxarYAq4jtx6U840bEX4FgkMCZOt2 -ClSZ29kT6g+BofpO/kHuubN5CrtOopavDKBYSr76JkjEBJYWkwHYN/ejNg8s/hNu -Scf4JVJXhbzRfqA96U+4jwKBgQCTDqLjgza1mzqh38j80vJDwJod4CFOkjYeNe2/ -jDIh09t3sazbk9GDlcXQNn2XDIbw2bnt6SgabVmN2o8eSVyqsbrEryRLlzA5YA9U -VotKJC/B1DX1rhYVBt5WnKWWWZc1r8JFJk0t5RvHaccMGQ1nVmzQk9MX4jCslaaL -RjgjBQKBgCvk0o4e2+3RNJvMiyaBZb7Ua930Jm05rRCsRNnPHEVnCCJV28v+RTLe -xpjLTezctxfW1MkJNueQlFyhMk6Y5w+KuyZnJDYqzjCgSBFQPl4UMC+Bt+A5rNm9 -5xgWKt08V8iSZtqHOSW9T2bjdjCcppkXOKAeDoBSTlPA8loC3M0R ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/django_crypto_fields/tests/etc/user-rsa-restricted-public.pem b/django_crypto_fields/tests/etc/user-rsa-restricted-public.pem deleted file mode 100644 index c6ff97e..0000000 --- a/django_crypto_fields/tests/etc/user-rsa-restricted-public.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvumZQWfPLe0AHUjV+WJq -xGALPBRioyUgA1ezIocls8fQzgb0Y7Y/L4n4vu/Bo9jAVSq3pW0wOhlJBCr4Mrhc -fhOu+xS/TqMP9ArI/PCzYkD+RpSRvicQZiHg8SliCfuNNGBpB9ZzbSDdsqvCbMR1 -52SxnPYQ6QEMjsZROpP6LlUSUhhhtTG62Uo/giU+vZKZ1CrKWgWdCl4n5nBSrb51 -uLlUArsgYyStAaVxMUZ2EL7tAJm6smy42K16KkT+RTBKfqfOuaWwMx4IewGQ5FCU -NoprE43HiASq7sNeY29v5MFYVMjfrV3ZpPlsBwr0OEFP/LmMRR3Hy1lBpv8IKm1o -DQIDAQAB ------END PUBLIC KEY----- \ No newline at end of file diff --git a/django_crypto_fields/tests/etc/user-salt-local.key b/django_crypto_fields/tests/etc/user-salt-local.key deleted file mode 100644 index ca00230..0000000 --- a/django_crypto_fields/tests/etc/user-salt-local.key +++ /dev/null @@ -1,4 +0,0 @@ -$_y<a[e.lQ~ۭo޸dq% y>VF -.dN"D˜'%V( D -,ڰ(dY R"LX, -) ywJT2GIp oh<@Vng˪Բd+$20t&B?` d'J(#'H Io,o=Z''G(bJgOWbxm \ No newline at end of file diff --git a/django_crypto_fields/tests/etc/user-salt-restricted.key b/django_crypto_fields/tests/etc/user-salt-restricted.key deleted file mode 100644 index 9753470206312dcf81392e9f20929e60ad24c874..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 256 zcmV+b0ssDZn@PC0ky0;Uckwhk!kYGTW-_G_VwX$E@s9K|JG0We;Duc0c(C&u4#`k7 zi5lkjLlQM!ql-7YkR7k;ZjCDOsQl^5Vx|er?R<@tLP#k6se7y+aTIL3mtqbsXx_A5 zRprZ3+1MMP^~aQ_3(mwU-=NI1vf`K|#OS?9q4T8Q&b68fI|oe5C4nHH!o1n5gy`}U GD7mRI0EIpP diff --git a/django_crypto_fields/tests/test_key_creator.py b/django_crypto_fields/tests/test_key_creator.py deleted file mode 100644 index 9ddfa35..0000000 --- a/django_crypto_fields/tests/test_key_creator.py +++ /dev/null @@ -1,141 +0,0 @@ -import os -from tempfile import mkdtemp - -from django.apps import apps as django_apps -from django.conf import settings -from django.test import TestCase -from django.test.utils import override_settings - -from ..key_creator import DjangoCryptoFieldsKeyAlreadyExist, KeyCreator -from ..key_files import KeyFiles -from ..key_path import ( - DjangoCryptoFieldsKeyPathDoesNotExist, - DjangoCryptoFieldsKeyPathError, - KeyPath, -) - -production_path_with_keys = mkdtemp() -production_path_without_keys = mkdtemp() - - -class TestKeyCreator(TestCase): - def setUp(self): - self.tmp_key_path = KeyPath(path=mkdtemp()) - self.key_path = KeyPath(path=production_path_with_keys) - self.key_path_without_keys = KeyPath(path=production_path_without_keys) - self.key_files = KeyFiles(key_path=self.key_path) - - def tearDown(self): - key_files = KeyFiles(key_path=self.tmp_key_path) - for file in key_files.files: - os.remove(file) - key_files = KeyFiles(key_path=self.key_path) - for file in key_files.files: - os.remove(file) - key_files = KeyFiles(key_path=self.key_path_without_keys) - for file in key_files.files: - os.remove(file) - - @override_settings(DEBUG=True) - def test_creator_creates_tmp_keys_for_debug_true(self): - app_config = django_apps.get_app_config("django_crypto_fields") - self.assertTrue(app_config.key_files.key_files_exist) - - def test_create_keys(self): - key_path = KeyPath(path=mkdtemp()) - key_files = KeyFiles(key_path=key_path) - creator = KeyCreator(key_files=key_files) - creator.create_keys() - - def test_create_keys_exist(self): - key_files = KeyFiles(key_path=self.tmp_key_path) - self.assertFalse(key_files.key_files_exist) - creator = KeyCreator(key_files=key_files) - creator.create_keys() - key_files = KeyFiles(key_path=self.tmp_key_path) - self.assertTrue(key_files.key_files_exist) - - @override_settings(DEBUG=False, KEY_PATH=KeyPath.non_production_path) - def test_create_keys_defaults_to_non_production_path_and_raises(self): - self.assertRaises(DjangoCryptoFieldsKeyPathError, KeyPath) - - @override_settings(DEBUG=False, KEY_PATH=os.path.join(settings.BASE_DIR, "crypto_fields")) - def test_create_keys_set_to_non_production_path_and_raises(self): - self.assertRaises(DjangoCryptoFieldsKeyPathError, KeyPath) - - @override_settings( - DEBUG=False, - KEY_PATH=os.path.join(settings.BASE_DIR, "this_path_does_not_exist"), - ) - def test_create_keys_set_to_production_path_and_raises(self): - app_config = django_apps.get_app_config("django_crypto_fields") - self.assertNotEqual( - app_config.key_path.path, - os.path.join(settings.BASE_DIR, "this_path_does_not_exist"), - ) - self.assertRaises( - DjangoCryptoFieldsKeyPathDoesNotExist, - KeyPath, - path=os.path.join(settings.BASE_DIR, "this_path_does_not_exist"), - ) - - @override_settings(DEBUG=False, KEY_PATH=production_path_with_keys) - def test_create_keys_does_not_overwrite_production_keys(self): - app_config = django_apps.get_app_config("django_crypto_fields") - creator = KeyCreator(key_files=app_config.key_files) - self.assertRaises(DjangoCryptoFieldsKeyAlreadyExist, creator.create_keys) - - @override_settings(DEBUG=False, KEY_PATH=production_path_without_keys) - def test_create_keys_set_to_production_path_and_raises3(self): - app_config = django_apps.get_app_config("django_crypto_fields") - # because "test" in sys.argv, this fails - self.assertNotEqual(app_config.key_path.path, production_path_without_keys) - self.assertEqual(settings.KEY_PATH, production_path_without_keys) - key_path = KeyPath(path=settings.KEY_PATH) - key_files = KeyFiles(key_path=key_path) - self.assertEqual(len(key_files.files), 0) - creator = KeyCreator(key_files=key_files) - creator.create_keys() - key_files = KeyFiles(key_path=key_path) - self.assertGreater(len(key_files.files), 0) - - # @override_settings(DEBUG=True, KEY_PATH=None) - # def test_default_path_for_debug(self): - # """Because this is a test, sets to the tmp path. - # - # Behavior is different for runserver. - # """ - # app_config = django_apps.get_app_config("django_crypto_fields") - # self.assertEqual(app_config.key_path.path, app_config.temp_path) - - @override_settings(DEBUG=False) - def test_default_path_in_production_raises(self): - self.assertFalse(settings.DEBUG) - self.assertRaises( - DjangoCryptoFieldsKeyPathError, KeyPath, path=KeyPath.non_production_path - ) - - def test_path(self): - path = mkdtemp() - key_path = KeyPath(path=path) - self.assertEqual(key_path.path, path) - - def test_key_filenames_modes(self): - key_files = KeyFiles(key_path=self.tmp_key_path) - self.assertEqual(len(list(key_files.key_filenames.keys())), 3) - self.assertEqual(list(key_files.key_filenames.keys()), ["rsa", "aes", "salt"]) - - def test_key_filenames_key_types_per_mode(self): - key_files = KeyFiles(key_path=self.tmp_key_path) - self.assertEqual(len(list(key_files.key_filenames.keys())), 3) - for value in key_files.key_filenames.values(): - key_types = list(value.keys()) - key_types.sort() - self.assertEqual(key_types, ["local", "restricted"]) - - def test_key_filenames_path_per_key_type(self): - key_files = KeyFiles(key_path=self.tmp_key_path) - - for mode in key_files.key_filenames.values(): - for key_type in mode.values(): - self.assertIn(key_files.key_path.path, list(key_type.values())[0]) diff --git a/django_crypto_fields/tests/test_settings.py b/django_crypto_fields/tests/test_settings.py new file mode 100644 index 0000000..a253f1b --- /dev/null +++ b/django_crypto_fields/tests/test_settings.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path + +from edc_test_settings.default_test_settings import DefaultTestSettings + +app_name = "django_crypto_fields" +base_dir = Path(__file__).absolute().parent + +project_settings = DefaultTestSettings( + calling_file=__file__, + BASE_DIR=base_dir, + APP_NAME=app_name, + DJANGO_CRYPTO_FIELDS_KEY_PATH=base_dir / "etc", + GIT_DIR=base_dir.parent.parent, + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.messages", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.staticfiles", + "django_revision.apps.AppConfig", + "edc_device.apps.AppConfig", + f"{app_name}.apps.AppConfig", + ], +).settings + +for k, v in project_settings.items(): + setattr(sys.modules[__name__], k, v) diff --git a/django_crypto_fields/tests/tests/__init__.py b/django_crypto_fields/tests/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_crypto_fields/tests/test_cryptor.py b/django_crypto_fields/tests/tests/test_cryptor.py similarity index 78% rename from django_crypto_fields/tests/test_cryptor.py rename to django_crypto_fields/tests/tests/test_cryptor.py index c28595e..e1f9144 100644 --- a/django_crypto_fields/tests/test_cryptor.py +++ b/django_crypto_fields/tests/tests/test_cryptor.py @@ -1,27 +1,24 @@ from datetime import datetime -from django.apps import apps as django_apps from django.test import TestCase -from ..constants import AES, LOCAL_MODE, RESTRICTED_MODE, RSA -from ..cryptor import Cryptor -from ..exceptions import EncryptionError +from django_crypto_fields.constants import AES, LOCAL_MODE, RESTRICTED_MODE, RSA +from django_crypto_fields.cryptor import Cryptor +from django_crypto_fields.exceptions import EncryptionError +from django_crypto_fields.keys import encryption_keys class TestCryptor(TestCase): - def setUp(self): - app_config = django_apps.get_app_config("django_crypto_fields") - self.keys = app_config.encryption_keys def test_mode_support(self): - self.assertEqual(self.keys.rsa_modes_supported, [LOCAL_MODE, RESTRICTED_MODE]) - self.assertEqual(self.keys.aes_modes_supported, [LOCAL_MODE, RESTRICTED_MODE]) + self.assertEqual(encryption_keys.rsa_modes_supported, [LOCAL_MODE, RESTRICTED_MODE]) + self.assertEqual(encryption_keys.aes_modes_supported, [LOCAL_MODE, RESTRICTED_MODE]) def test_encrypt_rsa(self): """Assert successful RSA roundtrip.""" cryptor = Cryptor() plaintext = "erik is a pleeb!!" - for mode in self.keys.rsa_modes_supported: + for mode in encryption_keys.rsa_modes_supported: ciphertext = cryptor.rsa_encrypt(plaintext, mode) self.assertEqual(plaintext, cryptor.rsa_decrypt(ciphertext, mode)) @@ -29,15 +26,15 @@ def test_encrypt_aes(self): """Assert successful AES roundtrip.""" cryptor = Cryptor() plaintext = "erik is a pleeb!!" - for mode in self.keys.aes_modes_supported: + for mode in encryption_keys.aes_modes_supported: ciphertext = cryptor.aes_encrypt(plaintext, mode) self.assertEqual(plaintext, cryptor.aes_decrypt(ciphertext, mode)) def test_encrypt_rsa_length(self): """Assert RSA raises EncryptionError if plaintext is too long.""" cryptor = Cryptor() - for mode in self.keys.rsa_modes_supported: - max_length = self.keys.rsa_key_info[mode]["max_message_length"] + for mode in encryption_keys.rsa_modes_supported: + max_length = encryption_keys.rsa_key_info[mode]["max_message_length"] plaintext = "".join(["a" for _ in range(0, max_length)]) cryptor.rsa_encrypt(plaintext, mode) self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext + "a", mode) @@ -72,7 +69,7 @@ def test_rsa_roundtrip(self): plaintext = ( "erik is a pleeb! ERIK IS A PLEEB 0123456789!@#$%^&*()" "_-+={[}]|\"':;>.<,?/~`±§" ) - for mode in cryptor.keys.key_filenames.get(RSA): + for mode in cryptor.keys.get(RSA): try: ciphertext = cryptor.rsa_encrypt(plaintext, mode) except (AttributeError, TypeError) as e: @@ -85,7 +82,7 @@ def test_aes_roundtrip(self): "erik is a pleeb!\nERIK IS A PLEEB\n0123456789!@#$%^&*()_" "-+={[}]|\"':;>.<,?/~`±§\n" ) - for mode in cryptor.keys.key_filenames[AES]: + for mode in cryptor.keys.get(AES): ciphertext = cryptor.aes_encrypt(plaintext, mode) self.assertTrue(plaintext != ciphertext) self.assertTrue(plaintext == cryptor.aes_decrypt(ciphertext, mode)) diff --git a/django_crypto_fields/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py similarity index 93% rename from django_crypto_fields/tests/test_field_cryptor.py rename to django_crypto_fields/tests/tests/test_field_cryptor.py index dd7c601..d042a44 100644 --- a/django_crypto_fields/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -1,19 +1,17 @@ -from django.apps import apps as django_apps from django.db import transaction from django.db.utils import IntegrityError from django.test import TestCase -from ..constants import AES, ENCODING, HASH_PREFIX, LOCAL_MODE, RSA -from ..cryptor import Cryptor -from ..exceptions import MalformedCiphertextError -from ..field_cryptor import FieldCryptor -from .models import TestModel +from django_crypto_fields.constants import AES, ENCODING, HASH_PREFIX, LOCAL_MODE, RSA +from django_crypto_fields.cryptor import Cryptor +from django_crypto_fields.exceptions import MalformedCiphertextError +from django_crypto_fields.field_cryptor import FieldCryptor + +from ...keys import encryption_keys +from ..models import TestModel class TestFieldCryptor(TestCase): - def setUp(self): - app_config = django_apps.get_app_config("django_crypto_fields") - self.keys = app_config.encryption_keys def test_can_verify_hash_as_none(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) @@ -83,7 +81,7 @@ def test_verify_value(self): def test_rsa_field_encryption(self): """Assert successful RSA field roundtrip.""" plaintext = "erik is a pleeb!!" - for mode in self.keys.key_filenames[RSA]: + for mode in encryption_keys.get(RSA): field_cryptor = FieldCryptor(RSA, mode) ciphertext = field_cryptor.encrypt(plaintext) self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) @@ -91,7 +89,7 @@ def test_rsa_field_encryption(self): def test_rsa_field_encryption_update_secret(self): """Assert successful AES field roundtrip for same value.""" plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - for mode in self.keys.key_filenames[RSA]: + for mode in encryption_keys.get(RSA): field_cryptor = FieldCryptor(RSA, mode) ciphertext1 = field_cryptor.encrypt(plaintext) self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext1)) @@ -102,7 +100,7 @@ def test_rsa_field_encryption_update_secret(self): def test_aes_field_encryption(self): """Assert successful RSA field roundtrip.""" plaintext = "erik is a pleeb!!" - for mode in self.keys.key_filenames[AES]: + for mode in encryption_keys.get(AES): field_cryptor = FieldCryptor(AES, mode) ciphertext = field_cryptor.encrypt(plaintext) self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) @@ -110,7 +108,7 @@ def test_aes_field_encryption(self): def test_rsa_field_encryption_encoded(self): """Assert successful RSA field roundtrip.""" plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - for mode in self.keys.key_filenames[RSA]: + for mode in encryption_keys.get(RSA): field_cryptor = FieldCryptor(RSA, mode) ciphertext = field_cryptor.encrypt(plaintext) self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) @@ -118,7 +116,7 @@ def test_rsa_field_encryption_encoded(self): def test_aes_field_encryption_encoded(self): """Assert successful AES field roundtrip.""" plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - for mode in self.keys.key_filenames[AES]: + for mode in encryption_keys.get(AES): field_cryptor = FieldCryptor(AES, mode) ciphertext = field_cryptor.encrypt(plaintext) self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) @@ -126,7 +124,7 @@ def test_aes_field_encryption_encoded(self): def test_aes_field_encryption_update_secret(self): """Assert successful AES field roundtrip for same value.""" plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - for mode in self.keys.key_filenames[AES]: + for mode in encryption_keys.get(AES): field_cryptor = FieldCryptor(AES, mode) ciphertext1 = field_cryptor.encrypt(plaintext) self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext1)) diff --git a/django_crypto_fields/tests/tests/test_keys.py b/django_crypto_fields/tests/tests/test_keys.py new file mode 100644 index 0000000..0fb4bf1 --- /dev/null +++ b/django_crypto_fields/tests/tests/test_keys.py @@ -0,0 +1,124 @@ +import os +from pathlib import Path +from tempfile import mkdtemp + +from django.conf import settings +from django.test import TestCase +from django.test.utils import override_settings, tag + +from django_crypto_fields.exceptions import ( + DjangoCryptoFieldsKeyAlreadyExist, + DjangoCryptoFieldsKeyPathDoesNotExist, + DjangoCryptoFieldsKeyPathError, +) +from django_crypto_fields.key_path import KeyPath +from django_crypto_fields.keys import Keys, encryption_keys +from django_crypto_fields.utils import get_keypath_from_settings + +production_path_with_keys = mkdtemp() +production_path_without_keys = mkdtemp() + + +class TestKeyCreator(TestCase): + def setUp(self): + encryption_keys.verbose = False + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass + + def tearDown(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass + + @tag("1") + @override_settings( + DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp(), DJANGO_CRYPTO_FIELDS_INITIALIZE=False + ) + def test_keys_do_not_exist(self): + encryption_keys.verbose = False + encryption_keys.initialize() + encryption_keys.reset(delete_all_keys="delete_all_keys") + for file in encryption_keys.files: + self.assertFalse(Path(file).exists()) + + @tag("1") + @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) + def test_keys_exist(self): + encryption_keys.verbose = False + encryption_keys.initialize() + for file in encryption_keys.files: + self.assertTrue(Path(file).exists()) + + @override_settings(DEBUG=False, DJANGO_CRYPTO_FIELDS_KEY_PATH="/blah/blah/blah/blah") + def test_create_keys_defaults_to_non_production_path_and_raises(self): + self.assertRaises(DjangoCryptoFieldsKeyPathDoesNotExist, KeyPath) + + @override_settings( + DEBUG=False, + DJANGO_CRYPTO_FIELDS_TEST_MODULE="blah.py", + DJANGO_CRYPTO_FIELDS_KEY_PATH=os.path.join(settings.BASE_DIR, "etc"), + ) + def test_create_keys_set_to_non_production_path_and_raises(self): + self.assertRaises(DjangoCryptoFieldsKeyPathError, KeyPath) + + @override_settings( + DEBUG=False, + DJANGO_CRYPTO_FIELDS_TEST_MODULE="blah.py", + DJANGO_CRYPTO_FIELDS_KEY_PATH=os.path.join( + settings.BASE_DIR, "this/path/does/not/exist" + ), + ) + def test_invalid_production_path_raises(self): + self.assertRaises(DjangoCryptoFieldsKeyPathDoesNotExist, KeyPath) + self.assertRaises(DjangoCryptoFieldsKeyPathDoesNotExist, Keys) + + @override_settings( + DEBUG=False, + DJANGO_CRYPTO_FIELDS_TEST_MODULE="blah.py", + DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp(), + ) + def test_create_keys_does_not_overwrite_production_keys(self): + keys = Keys(verbose=False) + keys.reset() + self.assertRaises(DjangoCryptoFieldsKeyAlreadyExist, keys.create) + + @tag("2") + @override_settings( + DEBUG=False, + DJANGO_CRYPTO_FIELDS_KEY_PATH=None, + DJANGO_CRYPTO_FIELDS_TEST_MODULE="blah.py", + ) + def test_default_path_in_production_raises(self): + self.assertFalse(settings.DEBUG) + self.assertRaises(DjangoCryptoFieldsKeyPathError, KeyPath) + + @tag("1") + @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) + def test_path(self): + path = get_keypath_from_settings() + key_path = KeyPath() + self.assertEqual(str(key_path.path), path) + + @tag("1") + def test_key_filenames_modes(self): + self.assertEqual(len(list(encryption_keys.template.keys())), 3) + self.assertEqual(list(encryption_keys.template.keys()), ["rsa", "aes", "salt"]) + + @tag("1") + @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=None) + def test_key_filenames_key_types_per_mode(self): + self.assertEqual(len(list(encryption_keys.template.keys())), 3) + for value in encryption_keys.template.values(): + key_types = list(value.keys()) + key_types.sort() + self.assertEqual(key_types, ["local", "restricted"]) + + @tag("1") + @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=None) + def test_key_filenames_path_per_key_type(self): + for mode in encryption_keys.template.values(): + for key_type in mode.values(): + self.assertIn(str(encryption_keys.path), str(list(key_type.values())[0])) diff --git a/django_crypto_fields/tests/test_models.py b/django_crypto_fields/tests/tests/test_models.py similarity index 89% rename from django_crypto_fields/tests/test_models.py rename to django_crypto_fields/tests/tests/test_models.py index caab3cf..d0fcddc 100644 --- a/django_crypto_fields/tests/test_models.py +++ b/django_crypto_fields/tests/tests/test_models.py @@ -1,11 +1,23 @@ from django.db.utils import IntegrityError from django.test import TestCase -from ..fields.base_field import BaseField -from .models import TestModel +from django_crypto_fields.fields.base_field import BaseField + +from ...keys import encryption_keys +from ..models import TestModel class TestModels(TestCase): + def setUp(self): + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass + def test_encrypt_rsa(self): """Assert deconstruct.""" test_model = TestModel() diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 09bd1c7..c59a509 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -1,21 +1,79 @@ from __future__ import annotations +import sys from typing import TYPE_CHECKING +from django.apps import apps as django_apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + if TYPE_CHECKING: + from django.db import models + from .fields import BaseField + from .models import Crypt + class AnyModel(models.Model): + class Meta: + verbose_name = "Any Model" -def has_encrypted_fields(model) -> bool: + +def has_encrypted_fields(model: AnyModel) -> bool: for field in model._meta.get_fields(): if hasattr(field, "field_cryptor"): return True return False -def get_encrypted_fields(model) -> list[BaseField]: +def get_encrypted_fields(model: models.Model) -> list[BaseField]: encrypted_fields = [] for field in model._meta.get_fields(): if hasattr(field, "field_cryptor"): encrypted_fields.append(field) return encrypted_fields + + +def get_crypt_model() -> str: + return getattr(settings, "DJANGO_CRYPTO_FIELDS_MODEL", "django_crypto_fields.crypt") + + +def get_crypt_model_cls() -> Crypt: + """Return the Crypt model that is active in this project.""" + try: + return django_apps.get_model(get_crypt_model(), require_ready=False) + except ValueError: + raise ImproperlyConfigured( + "Invalid. `settings.DJANGO_CRYPTO_FIELDS_MODEL` must refer to a model " + f"using lower_label format. Got {get_crypt_model()}." + ) + except LookupError: + raise ImproperlyConfigured( + "Invalid. `settings.DJANGO_CRYPTO_FIELDS_MODEL` refers to a model " + f"that has not been installed. Got {get_crypt_model()}." + ) + + +def get_auto_create_keys_from_settings() -> bool: + auto_create_keys = getattr( + settings, + "DJANGO_CRYPTO_FIELDS_AUTO_CREATE", + getattr(settings, "AUTO_CREATE_KEYS", None), + ) + if "runtests.py" in sys.argv: + if auto_create_keys is None: + auto_create_keys = True + return auto_create_keys + + +def get_keypath_from_settings() -> str: + return getattr( + settings, "DJANGO_CRYPTO_FIELDS_KEY_PATH", getattr(settings, "KEY_PATH", None) + ) + + +def get_test_module_from_settings() -> str: + return getattr(settings, "DJANGO_CRYPTO_FIELDS_TEST_MODULE", "runtests.py") + + +def get_key_prefix_from_settings() -> str: + return getattr(settings, "DJANGO_CRYPTO_FIELDS_KEY_PREFIX", "user") diff --git a/pyproject.toml b/pyproject.toml index bd47c0e..c4b4c95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,12 @@ version_file="_version.py" [tool.black] line-length = 95 -target-version = ["py311"] +target-version = ["py312"] extend-exclude = '''^(.*\/)*\b(migrations)\b($|\/.*$)''' [tool.isort] profile = "black" -py_version = "311" +py_version = "312" skip = [".tox", ".eggs", "migrations"] [tool.coverage.run] diff --git a/runtests.py b/runtests.py index 6e2b489..962a258 100644 --- a/runtests.py +++ b/runtests.py @@ -1,36 +1,18 @@ #!/usr/bin/env python -import logging -from pathlib import Path +# -*- coding: utf-8 -*- +import os +import sys -from edc_test_utils import DefaultTestSettings, func_main - -app_name = "django_crypto_fields" -base_dir = Path(__file__).absolute().parent - -project_settings = DefaultTestSettings( - calling_file=__file__, - BASE_DIR=base_dir, - APP_NAME=app_name, - ETC_DIR=str(base_dir / app_name / "tests" / "etc"), - INSTALLED_APPS=[ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.messages", - "django.contrib.sessions", - "django.contrib.sites", - "django.contrib.staticfiles", - "django_revision.apps.AppConfig", - "edc_device.apps.AppConfig", - f"{app_name}.apps.AppConfig", - ], -).settings - - -def main(): - func_main(project_settings, f"{app_name}.tests") +if __name__ == "__main__": + os.environ["DJANGO_SETTINGS_MODULE"] = "django_crypto_fields.tests.test_settings" + import django + django.setup() + from django.test.runner import DiscoverRunner -if __name__ == "__main__": - logging.basicConfig() - main() + tags = [t.split("=")[1] for t in sys.argv if t.startswith("--tag")] + failfast = any([True for t in sys.argv if t.startswith("--failfast")]) + keepdb = any([True for t in sys.argv if t.startswith("--keepdb")]) + opts = dict(failfast=failfast, tags=tags, keepdb=keepdb) + failures = DiscoverRunner(**opts).run_tests(["django_crypto_fields.tests"], **opts) + sys.exit(failures) From 68538502bb39b21c45ce50a0f35cd12a8333c995 Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 00:27:55 -0500 Subject: [PATCH 03/25] requirements --- django_crypto_fields/tests/models.py | 4 ++-- django_crypto_fields/tests/test_settings.py | 2 +- setup.cfg | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/django_crypto_fields/tests/models.py b/django_crypto_fields/tests/models.py index 6f9d8c4..3fd6642 100644 --- a/django_crypto_fields/tests/models.py +++ b/django_crypto_fields/tests/models.py @@ -1,12 +1,12 @@ from django.db import models from django.utils import timezone -from edc_model.models import BaseModel from ..fields import EncryptedTextField, FirstnameField, IdentityField, LastnameField from ..models import CryptoMixin -class TestModel(CryptoMixin, BaseModel): +class TestModel(CryptoMixin, models.Model): + firstname = FirstnameField(verbose_name="First Name", null=True) lastname = LastnameField(verbose_name="Last Name", null=True) diff --git a/django_crypto_fields/tests/test_settings.py b/django_crypto_fields/tests/test_settings.py index a253f1b..82e819f 100644 --- a/django_crypto_fields/tests/test_settings.py +++ b/django_crypto_fields/tests/test_settings.py @@ -21,7 +21,7 @@ "django.contrib.sites", "django.contrib.staticfiles", "django_revision.apps.AppConfig", - "edc_device.apps.AppConfig", + # "edc_device.apps.AppConfig", f"{app_name}.apps.AppConfig", ], ).settings diff --git a/setup.cfg b/setup.cfg index 568646a..54daedf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,10 @@ install_requires = edc-utils edc-model-fields django-extensions + django-revision + +tests_require = + edc-test-settings [options.packages.find] exclude = From 469e949b9a69fbd9b485a125bf5a4468e0c4e509 Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 11:46:05 -0500 Subject: [PATCH 04/25] remove DJANGO_CRYPTO_FIELDS_INITIALIZE --- CHANGES | 1 + django_crypto_fields/fields/base_field.py | 2 +- .../fields/firstname_field.py | 36 ++-- django_crypto_fields/keys/__init__.py | 3 + django_crypto_fields/{ => keys}/keys.py | 163 +++++++----------- django_crypto_fields/keys/utils.py | 59 +++++++ django_crypto_fields/system_checks.py | 80 --------- django_crypto_fields/tests/models.py | 9 +- .../tests/tests/test_cryptor.py | 13 ++ .../tests/tests/test_field_cryptor.py | 15 +- django_crypto_fields/tests/tests/test_keys.py | 20 +-- .../tests/tests/test_models.py | 7 +- pyproject.toml | 12 +- 13 files changed, 195 insertions(+), 225 deletions(-) create mode 100644 django_crypto_fields/keys/__init__.py rename django_crypto_fields/{ => keys}/keys.py (60%) create mode 100644 django_crypto_fields/keys/utils.py delete mode 100644 django_crypto_fields/system_checks.py diff --git a/CHANGES b/CHANGES index 858c4ba..c693ead 100644 --- a/CHANGES +++ b/CHANGES @@ -12,6 +12,7 @@ CHANGES - change settings.KEY_PATH to settings.DJANGO_CRYPTO_FIELDS_KEY_PATH. (settings.KEY_PATH will still work) - use pathlib instead of os +- merge system checks into Keys validation - drop support for py3.8, 3.9, 3.10 and Django 3.2, 4.0, 4.1 0.3.10 diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index f05f7d2..12504fc 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -26,7 +26,7 @@ def __init__(self, algorithm, mode, *args, **kwargs): self.keys = encryption_keys if not encryption_keys.loaded: raise DjangoCryptoFieldsKeysNotLoaded( - "Encryption keys not loaded. You need to run initialize().x" + "Encryption keys not loaded. You need to run initialize()" ) self.algorithm = algorithm or RSA self.mode = mode or LOCAL_MODE diff --git a/django_crypto_fields/fields/firstname_field.py b/django_crypto_fields/fields/firstname_field.py index 8aab55f..ac48116 100644 --- a/django_crypto_fields/fields/firstname_field.py +++ b/django_crypto_fields/fields/firstname_field.py @@ -26,20 +26,22 @@ def validate_with_cleaned_data(self, attname, cleaned_data): "Ensure initials are letters (A-Z) in upper case, " "no spaces or numbers." ) - # check first and last initial matches first and last name - if initials and first_name: - if first_name[:1].upper() != initials[:1].upper(): - raise ValidationError( - "First initial does not match first name, " - "expected '{}' but you wrote '{}'.".format( - first_name[:1], initials[:1] - ) - ) - if initials and last_name: - if last_name[:1].upper() != initials[-1:].upper(): - raise ValidationError( - "Last initial does not match last name, " - "expected '{}' but you wrote '{}'.".format( - last_name[:1], initials[-1:] - ) - ) + self.check_initials_and_firstname(initials, first_name) + self.check_initials_and_lastname(initials, last_name) + + @staticmethod + def check_initials_and_firstname(initials, first_name) -> None: + """Check first and last initial matches first and last name""" + if initials and first_name and first_name[:1].upper() != initials[:1].upper(): + raise ValidationError( + "First initial does not match first name, " + "expected '{}' but you wrote '{}'.".format(first_name[:1], initials[:1]) + ) + + @staticmethod + def check_initials_and_lastname(initials, last_name) -> None: + if initials and last_name and last_name[:1].upper() != initials[-1:].upper(): + raise ValidationError( + "Last initial does not match last name, " + "expected '{}' but you wrote '{}'.".format(last_name[:1], initials[-1:]) + ) diff --git a/django_crypto_fields/keys/__init__.py b/django_crypto_fields/keys/__init__.py new file mode 100644 index 0000000..1879a9b --- /dev/null +++ b/django_crypto_fields/keys/__init__.py @@ -0,0 +1,3 @@ +from .keys import Keys, encryption_keys + +__all__ = ["encryption_keys", "Keys"] diff --git a/django_crypto_fields/keys.py b/django_crypto_fields/keys/keys.py similarity index 60% rename from django_crypto_fields/keys.py rename to django_crypto_fields/keys/keys.py index 71a5662..25a11b1 100644 --- a/django_crypto_fields/keys.py +++ b/django_crypto_fields/keys/keys.py @@ -1,36 +1,32 @@ from __future__ import annotations import os -import sys from copy import deepcopy -from pathlib import Path, PurePath +from pathlib import Path from Cryptodome import Random from Cryptodome.Cipher import PKCS1_OAEP from Cryptodome.PublicKey import RSA as RSA_PUBLIC_KEY from Cryptodome.Util import number -from django.conf import settings from django.core.management.color import color_style -from .constants import ( - AES, - LOCAL_MODE, - PRIVATE, - PUBLIC, - RESTRICTED_MODE, - RSA, - RSA_KEY_SIZE, - SALT, -) -from .exceptions import ( +from django_crypto_fields.constants import AES, PRIVATE, PUBLIC, RSA, RSA_KEY_SIZE, SALT +from django_crypto_fields.exceptions import ( DjangoCryptoFieldsError, DjangoCryptoFieldsKeyAlreadyExist, DjangoCryptoFieldsKeyError, DjangoCryptoFieldsKeysAlreadyLoaded, DjangoCryptoFieldsKeysDoNotExist, ) -from .key_path import KeyPath, persist_key_path_or_raise -from .utils import get_auto_create_keys_from_settings, get_key_prefix_from_settings +from django_crypto_fields.key_path import KeyPath, persist_key_path_or_raise +from django_crypto_fields.utils import ( + get_auto_create_keys_from_settings, + get_key_prefix_from_settings, +) + +from .utils import get_filenames, get_template, write_msg + +style = color_style() class Keys: @@ -50,84 +46,47 @@ def __init__(self, verbose: bool = None): self.verbose = True if verbose is None else verbose self.rsa_modes_supported = None self.aes_modes_supported = None - self.files: list[str] = [] - self.path: PurePath = KeyPath().path - self.template: dict[str, dict[str, dict[str, RSA_PUBLIC_KEY]]] = { - RSA: { - RESTRICTED_MODE: { - PUBLIC: self.path / (self.key_prefix + "-rsa-restricted-public.pem"), - PRIVATE: self.path / (self.key_prefix + "-rsa-restricted-private.pem"), - }, - LOCAL_MODE: { - PUBLIC: self.path / (self.key_prefix + "-rsa-local-public.pem"), - PRIVATE: self.path / (self.key_prefix + "-rsa-local-private.pem"), - }, - }, - AES: { - LOCAL_MODE: {PRIVATE: self.path / (self.key_prefix + "-aes-local.key")}, - RESTRICTED_MODE: { - PRIVATE: self.path / (self.key_prefix + "-aes-restricted.key"), - }, - }, - SALT: { - LOCAL_MODE: {PRIVATE: self.path / (self.key_prefix + "-salt-local.key")}, - RESTRICTED_MODE: { - PRIVATE: self.path / (self.key_prefix + "-salt-restricted.key"), - }, - }, - } - for _, v in self.template.items(): - for _, _v in v.items(): - for _, fname in _v.items(): - self.files.append(fname) + self.path = KeyPath().path + self.template = get_template(self.path, self.key_prefix) + self.files = get_filenames(self.path, self.key_prefix) self.initialize() def initialize(self): """Load keys and create if necessary.""" - style = color_style() - if not getattr(settings, "DJANGO_CRYPTO_FIELDS_INITIALIZE", True): - sys.stdout.write( - style.ERROR( - " * NOT LOADING ENCRYPTION KEYS, see " - "settings.DJANGO_CRYPTO_FIELDS_INITIALIZE\n" - ) - ) - else: - self.write("Loading encryption keys\n") - self.keys = deepcopy(self.template) - persist_key_path_or_raise() - if not self.key_files_exist: - if auto_create_keys := get_auto_create_keys_from_settings(): - if not os.access(self.path, os.W_OK): - raise DjangoCryptoFieldsError( - "Cannot auto-create encryption keys. Folder is not writeable." - f"Got {self.path}" - ) - self.write( - style.SUCCESS(f" * settings.AUTO_CREATE_KEYS={auto_create_keys}.\n") - ) - self.create() - else: - raise DjangoCryptoFieldsKeysDoNotExist( - f"Failed to find any encryption keys in path {self.path}. " - "If this is your first time loading " - "the project, set settings.AUTO_CREATE_KEYS=True and restart. " - "Make sure the folder is writeable." + write_msg(self.verbose, "Loading encryption keys\n") + self.keys = deepcopy(self.template) + persist_key_path_or_raise() + if not self.key_files_exist: + if auto_create_keys := get_auto_create_keys_from_settings(): + if not os.access(self.path, os.W_OK): + raise DjangoCryptoFieldsError( + "Cannot auto-create encryption keys. Folder is not writeable." + f"Got {self.path}" ) - self.load_keys() - self.rsa_modes_supported = sorted([k for k in self.keys[RSA]]) - self.aes_modes_supported = sorted([k for k in self.keys[AES]]) - self.write(" Done loading encryption keys\n") + write_msg( + self.verbose, + style.SUCCESS(f" * settings.AUTO_CREATE_KEYS={auto_create_keys}.\n"), + ) + self.create() + else: + raise DjangoCryptoFieldsKeysDoNotExist( + f"Failed to find any encryption keys in path {self.path}. " + "If this is your first time loading " + "the project, set settings.AUTO_CREATE_KEYS=True and restart. " + "Make sure the folder is writeable." + ) + self.load_keys() + self.rsa_modes_supported = sorted([k for k in self.keys[RSA]]) + self.aes_modes_supported = sorted([k for k in self.keys[AES]]) + write_msg(self.verbose, " Done loading encryption keys\n") def reset(self, delete_all_keys: str = None, verbose: bool = None): """Use with extreme care!""" - verbose = True if verbose is None else verbose + verbose = self.verbose if verbose is None else verbose self.keys = deepcopy(self.template) self.loaded = False if delete_all_keys == "delete_all_keys": - if verbose: - style = color_style() - sys.stdout.write(style.ERROR(" * Deleting encryption keys\n")) + write_msg(verbose, style.ERROR(" * Deleting encryption keys\n")) for file in encryption_keys.files: try: Path(file).unlink() @@ -139,22 +98,17 @@ def get(self, k: str): def create(self) -> None: """Generates RSA and AES keys as per `filenames`.""" - style = color_style() if self.key_files_exist: raise DjangoCryptoFieldsKeyAlreadyExist( f"Not creating new keys. Encryption keys already exist. See {self.path}." ) - self.write(style.WARNING(" * Generating new encryption keys ...\n")) + write_msg(self.verbose, style.WARNING(" * Generating new encryption keys ...\n")) self._create_rsa() self._create_aes() self._create_salt() - self.write(" Done generating new encryption keys.\n") - self.write(f" Your new encryption keys are in {self.path}.\n") - self.write(style.ERROR(" DON'T FORGET TO BACKUP YOUR NEW KEYS!!\n")) - - def write(self, msg: str): - if self.verbose: - sys.stdout.write(msg) + write_msg(self.verbose, " Done generating new encryption keys.\n") + write_msg(self.verbose, f" Your new encryption keys are in {self.path}.\n") + write_msg(self.verbose, style.ERROR(" DON'T FORGET TO BACKUP YOUR NEW KEYS!!\n")) def load_keys(self) -> None: """Loads all keys defined in self.filenames.""" @@ -162,20 +116,20 @@ def load_keys(self) -> None: raise DjangoCryptoFieldsKeysAlreadyLoaded( f"Encryption keys have already been loaded. Path='{self.path}'." ) - self.write(f" * loading keys from {self.path}\n") + write_msg(self.verbose, f" * loading keys from {self.path}\n") for mode, keys in self.keys[RSA].items(): for key in keys: - self.write(f" * loading {RSA}.{mode}.{key} ...\r") + write_msg(self.verbose, f" * loading {RSA}.{mode}.{key} ...\r") self.load_rsa_key(mode, key) - self.write(f" * loading {RSA}.{mode}.{key} ... Done.\n") + write_msg(self.verbose, f" * loading {RSA}.{mode}.{key} ... Done.\n") for mode in self.keys[AES]: - self.write(f" * loading {AES}.{mode} ...\r") + write_msg(self.verbose, f" * loading {AES}.{mode} ...\r") self.load_aes_key(mode) - self.write(f" * loading {AES}.{mode} ... Done.\n") + write_msg(self.verbose, f" * loading {AES}.{mode} ... Done.\n") for mode in self.keys[SALT]: - self.write(f" * loading {SALT}.{mode} ...\r") + write_msg(self.verbose, f" * loading {SALT}.{mode} ...\r") self.load_salt_key(mode, key) - self.write(f" * loading {SALT}.{mode} ... Done.\n") + write_msg(self.verbose, f" * loading {SALT}.{mode} ... Done.\n") self.loaded = True def load_rsa_key(self, mode, key) -> None: @@ -248,11 +202,11 @@ def _create_rsa(self, mode=None) -> None: try: with path.open(mode="xb") as f1: f1.write(pub.exportKey("PEM")) - self.write(f" - Created new RSA {mode} key {path}\n") + write_msg(self.verbose, f" - Created new RSA {mode} key {path}\n") path = Path(self.keys.get(RSA).get(mode).get(PRIVATE)) with open(path, "xb") as f2: f2.write(key.exportKey("PEM")) - self.write(f" - Created new RSA {mode} key {path}\n") + write_msg(self.verbose, f" - Created new RSA {mode} key {path}\n") except FileExistsError as e: raise DjangoCryptoFieldsKeyError(f"RSA key already exists. Got {e}") @@ -267,7 +221,7 @@ def _create_aes(self, mode=None) -> None: path = Path(self.keys.get(AES).get(mode).get(PRIVATE)) with path.open(mode="xb") as f: f.write(rsa_key.encrypt(aes_key)) - self.write(f" - Created new AES {mode} key {path}\n") + write_msg(self.verbose, f" - Created new AES {mode} key {path}\n") def _create_salt(self, mode=None) -> None: """Creates a salt and RSA encrypts it.""" @@ -280,12 +234,13 @@ def _create_salt(self, mode=None) -> None: path = Path(self.keys.get(SALT).get(mode).get(PRIVATE)) with path.open(mode="xb") as f: f.write(rsa_key.encrypt(salt)) - self.write(f" - Created new salt {mode} key {path}\n") + write_msg(self.verbose, f" - Created new salt {mode} key {path}\n") @property def key_files_exist(self) -> bool: + """Return True if any key files exist in the key path.""" key_files_exist = False - for group, key_group in self.keys.items(): + for group, key_group in self.template.items(): for mode, keys in key_group.items(): for key in keys: if Path(self.keys[group][mode][key]).exists(): diff --git a/django_crypto_fields/keys/utils.py b/django_crypto_fields/keys/utils.py new file mode 100644 index 0000000..0b23673 --- /dev/null +++ b/django_crypto_fields/keys/utils.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import sys +from pathlib import PurePath + +from django_crypto_fields.constants import ( + AES, + LOCAL_MODE, + PRIVATE, + PUBLIC, + RESTRICTED_MODE, + RSA, + SALT, +) + + +def get_template(path: PurePath, key_prefix: str) -> dict[str, dict[str, dict[str, PurePath]]]: + """Returns the data structure to store encryption keys. + + The Keys class will replace the filenames with the actual keys. + """ + return { + RSA: { + RESTRICTED_MODE: { + PUBLIC: path / (key_prefix + "-rsa-restricted-public.pem"), + PRIVATE: path / (key_prefix + "-rsa-restricted-private.pem"), + }, + LOCAL_MODE: { + PUBLIC: path / (key_prefix + "-rsa-local-public.pem"), + PRIVATE: path / (key_prefix + "-rsa-local-private.pem"), + }, + }, + AES: { + LOCAL_MODE: {PRIVATE: path / (key_prefix + "-aes-local.key")}, + RESTRICTED_MODE: { + PRIVATE: path / (key_prefix + "-aes-restricted.key"), + }, + }, + SALT: { + LOCAL_MODE: {PRIVATE: path / (key_prefix + "-salt-local.key")}, + RESTRICTED_MODE: { + PRIVATE: path / (key_prefix + "-salt-restricted.key"), + }, + }, + } + + +def get_filenames(path: PurePath, key_prefix: str) -> list[PurePath]: + filenames = [] + for _, v in get_template(path, key_prefix).items(): + for _, _v in v.items(): + for _, filename in _v.items(): + filenames.append(filename) + return filenames + + +def write_msg(verbose, msg: str): + if verbose: + sys.stdout.write(msg) diff --git a/django_crypto_fields/system_checks.py b/django_crypto_fields/system_checks.py deleted file mode 100644 index 4409ade..0000000 --- a/django_crypto_fields/system_checks.py +++ /dev/null @@ -1,80 +0,0 @@ -# import os -# import sys -# from collections import namedtuple -# -# from Cryptodome.Cipher import AES -# from django.apps import apps as django_apps -# from django.conf import settings -# from django.core.checks import Critical, Error -# -# from .cryptor import Cryptor -# from .exceptions import ( -# DjangoCryptoFieldsKeyPathChangeError, -# DjangoCryptoFieldsKeyPathError, -# ) -# from .key_path import KeyPath -# from .keys import encryption_keys -# from .utils import persist_key_path -# -# err = namedtuple("Err", "id cls") -# -# error_configs = dict( -# key_path_check=err("django_crypto_fields.C001", Critical), -# encryption_keys_check=err("django_crypto_fields.E001", Error), -# aes_mode_check=err("django_crypto_fields.E002", Error), -# ) -# -# -# def testing(): -# if "test" in sys.argv: -# return True -# if "runtests" in sys.argv: -# return True -# return False -# -# -# def key_path_check(app_configs, **kwargs): -# errors = [] -# if not settings.DEBUG: -# key_path = KeyPath() -# error = error_configs.get("key_path_check") -# filename = os.path.join(settings.ETC_DIR, "django_crypto_fields") -# hint = f"settings.KEY_PATH does not match the path stored in {filename}." -# try: -# persist_key_path(key_path=key_path, filename=filename) -# except ( -# DjangoCryptoFieldsKeyPathChangeError, -# DjangoCryptoFieldsKeyPathError, -# ) as e: -# error_msg = str(e) -# errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) -# return errors -# -# -# def encryption_keys_check(app_configs, **kwargs): -# app_config = django_apps.get_app_config("django_crypto_fields") -# errors = [] -# try: -# auto_create_keys = settings.AUTO_CREATE_KEYS -# except AttributeError: -# auto_create_keys = None -# if encryption_keys.key_files_exist and auto_create_keys and not testing(): -# error = error_configs.get("encryption_keys_check") -# error_msg = "settings.AUTO_CREATE_KEYS may not be 'True' when encryption keys exist." -# hint = ( -# "Did you backup your keys? Perhaps you just created new keys, " -# "to continue, set AUTO_CREATE_KEYS=False and restart." -# ) -# errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) -# return errors -# -# -# def aes_mode_check(app_configs, **kwargs): -# error = error_configs.get("aes_mode_check") -# errors = [] -# hint = "See django_crypto_fields.cryptor.py and comments in pycryptodomex.blockalgo.py." -# cryptor = Cryptor() -# if cryptor.aes_encryption_mode == AES.MODE_CFB: -# error_msg = "Encryption mode MODE_CFB should not be used." -# errors.append(error.cls(error_msg, hint=hint, obj=None, id=error.id)) -# return errors diff --git a/django_crypto_fields/tests/models.py b/django_crypto_fields/tests/models.py index 3fd6642..80837ce 100644 --- a/django_crypto_fields/tests/models.py +++ b/django_crypto_fields/tests/models.py @@ -1,8 +1,13 @@ from django.db import models from django.utils import timezone -from ..fields import EncryptedTextField, FirstnameField, IdentityField, LastnameField -from ..models import CryptoMixin +from django_crypto_fields.fields import ( + EncryptedTextField, + FirstnameField, + IdentityField, + LastnameField, +) +from django_crypto_fields.models import CryptoMixin class TestModel(CryptoMixin, models.Model): diff --git a/django_crypto_fields/tests/tests/test_cryptor.py b/django_crypto_fields/tests/tests/test_cryptor.py index e1f9144..9925ffd 100644 --- a/django_crypto_fields/tests/tests/test_cryptor.py +++ b/django_crypto_fields/tests/tests/test_cryptor.py @@ -9,6 +9,19 @@ class TestCryptor(TestCase): + def setUp(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass def test_mode_support(self): self.assertEqual(encryption_keys.rsa_modes_supported, [LOCAL_MODE, RESTRICTED_MODE]) diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py index d042a44..b908474 100644 --- a/django_crypto_fields/tests/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -6,12 +6,25 @@ from django_crypto_fields.cryptor import Cryptor from django_crypto_fields.exceptions import MalformedCiphertextError from django_crypto_fields.field_cryptor import FieldCryptor +from django_crypto_fields.keys import encryption_keys -from ...keys import encryption_keys from ..models import TestModel class TestFieldCryptor(TestCase): + def setUp(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass def test_can_verify_hash_as_none(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) diff --git a/django_crypto_fields/tests/tests/test_keys.py b/django_crypto_fields/tests/tests/test_keys.py index 0fb4bf1..f8857e2 100644 --- a/django_crypto_fields/tests/tests/test_keys.py +++ b/django_crypto_fields/tests/tests/test_keys.py @@ -4,7 +4,7 @@ from django.conf import settings from django.test import TestCase -from django.test.utils import override_settings, tag +from django.test.utils import override_settings from django_crypto_fields.exceptions import ( DjangoCryptoFieldsKeyAlreadyExist, @@ -21,11 +21,12 @@ class TestKeyCreator(TestCase): def setUp(self): - encryption_keys.verbose = False try: encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) except FileNotFoundError: pass + encryption_keys.verbose = False + encryption_keys.initialize() def tearDown(self): try: @@ -33,20 +34,16 @@ def tearDown(self): except FileNotFoundError: pass - @tag("1") - @override_settings( - DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp(), DJANGO_CRYPTO_FIELDS_INITIALIZE=False - ) + @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) def test_keys_do_not_exist(self): encryption_keys.verbose = False - encryption_keys.initialize() encryption_keys.reset(delete_all_keys="delete_all_keys") for file in encryption_keys.files: self.assertFalse(Path(file).exists()) - @tag("1") @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) def test_keys_exist(self): + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) encryption_keys.verbose = False encryption_keys.initialize() for file in encryption_keys.files: @@ -82,10 +79,9 @@ def test_invalid_production_path_raises(self): ) def test_create_keys_does_not_overwrite_production_keys(self): keys = Keys(verbose=False) - keys.reset() + keys.reset(verbose=False) self.assertRaises(DjangoCryptoFieldsKeyAlreadyExist, keys.create) - @tag("2") @override_settings( DEBUG=False, DJANGO_CRYPTO_FIELDS_KEY_PATH=None, @@ -95,19 +91,16 @@ def test_default_path_in_production_raises(self): self.assertFalse(settings.DEBUG) self.assertRaises(DjangoCryptoFieldsKeyPathError, KeyPath) - @tag("1") @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) def test_path(self): path = get_keypath_from_settings() key_path = KeyPath() self.assertEqual(str(key_path.path), path) - @tag("1") def test_key_filenames_modes(self): self.assertEqual(len(list(encryption_keys.template.keys())), 3) self.assertEqual(list(encryption_keys.template.keys()), ["rsa", "aes", "salt"]) - @tag("1") @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=None) def test_key_filenames_key_types_per_mode(self): self.assertEqual(len(list(encryption_keys.template.keys())), 3) @@ -116,7 +109,6 @@ def test_key_filenames_key_types_per_mode(self): key_types.sort() self.assertEqual(key_types, ["local", "restricted"]) - @tag("1") @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=None) def test_key_filenames_path_per_key_type(self): for mode in encryption_keys.template.values(): diff --git a/django_crypto_fields/tests/tests/test_models.py b/django_crypto_fields/tests/tests/test_models.py index d0fcddc..e62bf21 100644 --- a/django_crypto_fields/tests/tests/test_models.py +++ b/django_crypto_fields/tests/tests/test_models.py @@ -2,13 +2,18 @@ from django.test import TestCase from django_crypto_fields.fields.base_field import BaseField +from django_crypto_fields.keys import encryption_keys -from ...keys import encryption_keys from ..models import TestModel class TestModels(TestCase): + def setUp(self): + try: + encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + except FileNotFoundError: + pass encryption_keys.verbose = False encryption_keys.initialize() diff --git a/pyproject.toml b/pyproject.toml index c4b4c95..081e963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,16 +54,18 @@ DJANGO = [testenv] deps = - -r https://raw.githubusercontent.com/clinicedc/edc/develop/requirements.tests/tox.txt - -r https://raw.githubusercontent.com/clinicedc/edc/develop/requirements.tests/test_utils.txt - -r https://raw.githubusercontent.com/clinicedc/edc/develop/requirements.tests/edc.txt - -r https://raw.githubusercontent.com/clinicedc/edc/develop/requirements.tests/third_party_dev.txt + pre-commit + git+https://github.com/clinicedc/edc-test-utils@develop + git+https://github.com/clinicedc/edc-test-settingss@develop + coverage[toml]==6.4.3 + tox>=3.25 + tox-gh-actions>=2.4.0 dj42: Django>=4.2,<5.0 dj50: Django>=5.0 djdev: https://github.com/django/django/tarball/main commands = - pip install -U pip coverage[toml] + pip install -U pip pip --version pip freeze coverage run -a runtests.py From 376bcf85ad31c05cece0430ffa52304ac97454b8 Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 11:47:13 -0500 Subject: [PATCH 05/25] CHANGES --- CHANGES | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index c693ead..49d31da 100644 --- a/CHANGES +++ b/CHANGES @@ -11,9 +11,10 @@ CHANGES - name global Keys instance 'encryption_keys'. - change settings.KEY_PATH to settings.DJANGO_CRYPTO_FIELDS_KEY_PATH. (settings.KEY_PATH will still work) +- change settings.AUTO_CREATE_KEYS to settings.DJANGO_CRYPTO_FIELDS_AUTO_CREATE. + (settings.AUTO_CREATE_KEYS will still work) - use pathlib instead of os - merge system checks into Keys validation -- drop support for py3.8, 3.9, 3.10 and Django 3.2, 4.0, 4.1 0.3.10 ------ From 92da06bb696e4826dfce49d71bf0f5a9412d7398 Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 11:51:37 -0500 Subject: [PATCH 06/25] CHANGES --- CHANGES | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 49d31da..e4e046c 100644 --- a/CHANGES +++ b/CHANGES @@ -2,8 +2,8 @@ CHANGES 0.4.0 ----- -- merge functionality of key_creator and key_files into keys module, simplify - and refactor. +- merge functionality of key_creator and key_files into keys module, + simplify and refactor. - refactor KeyPath - initialize / create encryption_keys in constructor of Keys class. - set Keys instance in keys module and import from there instead of @@ -11,10 +11,11 @@ CHANGES - name global Keys instance 'encryption_keys'. - change settings.KEY_PATH to settings.DJANGO_CRYPTO_FIELDS_KEY_PATH. (settings.KEY_PATH will still work) -- change settings.AUTO_CREATE_KEYS to settings.DJANGO_CRYPTO_FIELDS_AUTO_CREATE. +- change settings.AUTO_CREATE_KEYS to + settings.DJANGO_CRYPTO_FIELDS_AUTO_CREATE. (settings.AUTO_CREATE_KEYS will still work) - use pathlib instead of os -- merge system checks into Keys validation +- remove system checks, raise exceptions when Keys is instantiated. 0.3.10 ------ From de43bd5e34125bb3fa85d984e089f6a27f49f90b Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 11:52:39 -0500 Subject: [PATCH 07/25] project.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 081e963..506f8be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ DJANGO = deps = pre-commit git+https://github.com/clinicedc/edc-test-utils@develop - git+https://github.com/clinicedc/edc-test-settingss@develop + git+https://github.com/clinicedc/edc-test-settings@develop coverage[toml]==6.4.3 tox>=3.25 tox-gh-actions>=2.4.0 From d75f755cbfee7a6d3d9274365628e7055c214905 Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 15:15:14 -0500 Subject: [PATCH 08/25] project.toml tox requirements --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 506f8be..0b3adbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,12 +55,12 @@ DJANGO = [testenv] deps = pre-commit - git+https://github.com/clinicedc/edc-test-utils@develop - git+https://github.com/clinicedc/edc-test-settings@develop - coverage[toml]==6.4.3 - tox>=3.25 - tox-gh-actions>=2.4.0 - dj42: Django>=4.2,<5.0 + edc-test-utils + edc-test-settings==0.1.2 + coverage[toml] + tox + tox-gh-actions + dj42: Django==4.2.11 dj50: Django>=5.0 djdev: https://github.com/django/django/tarball/main From 9909be85b794ffe51f4eae80c3afe700def33c5a Mon Sep 17 00:00:00 2001 From: erikvw Date: Sun, 17 Mar 2024 20:57:33 -0500 Subject: [PATCH 09/25] fix test key folder --- django_crypto_fields/tests/crypto_keys/django_crypto_fields | 2 ++ django_crypto_fields/tests/test_settings.py | 3 +-- django_crypto_fields/tests/tests/test_keys.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 django_crypto_fields/tests/crypto_keys/django_crypto_fields diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields new file mode 100644 index 0000000..14e3ada --- /dev/null +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -0,0 +1,2 @@ +path,date +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 01:56:23.839893+00:00 diff --git a/django_crypto_fields/tests/test_settings.py b/django_crypto_fields/tests/test_settings.py index 82e819f..952211e 100644 --- a/django_crypto_fields/tests/test_settings.py +++ b/django_crypto_fields/tests/test_settings.py @@ -10,7 +10,7 @@ calling_file=__file__, BASE_DIR=base_dir, APP_NAME=app_name, - DJANGO_CRYPTO_FIELDS_KEY_PATH=base_dir / "etc", + DJANGO_CRYPTO_FIELDS_KEY_PATH=base_dir / "crypto_keys", GIT_DIR=base_dir.parent.parent, INSTALLED_APPS=[ "django.contrib.admin", @@ -21,7 +21,6 @@ "django.contrib.sites", "django.contrib.staticfiles", "django_revision.apps.AppConfig", - # "edc_device.apps.AppConfig", f"{app_name}.apps.AppConfig", ], ).settings diff --git a/django_crypto_fields/tests/tests/test_keys.py b/django_crypto_fields/tests/tests/test_keys.py index f8857e2..f29c213 100644 --- a/django_crypto_fields/tests/tests/test_keys.py +++ b/django_crypto_fields/tests/tests/test_keys.py @@ -56,7 +56,7 @@ def test_create_keys_defaults_to_non_production_path_and_raises(self): @override_settings( DEBUG=False, DJANGO_CRYPTO_FIELDS_TEST_MODULE="blah.py", - DJANGO_CRYPTO_FIELDS_KEY_PATH=os.path.join(settings.BASE_DIR, "etc"), + DJANGO_CRYPTO_FIELDS_KEY_PATH=None, ) def test_create_keys_set_to_non_production_path_and_raises(self): self.assertRaises(DjangoCryptoFieldsKeyPathError, KeyPath) From c6ca44bece6ad1d2f28bc13527b7bdba217aa93b Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 07:52:40 -0500 Subject: [PATCH 10/25] load keys before models load (apps.py), minor code cleanup --- README.rst | 9 ++ django_crypto_fields/apps.py | 5 ++ django_crypto_fields/cryptor.py | 13 +-- django_crypto_fields/field_cryptor.py | 85 ++++++++++--------- django_crypto_fields/fields/base_field.py | 35 ++++---- .../templatetags/crypto_tags.py | 2 +- .../tests/crypto_keys/django_crypto_fields | 2 +- 7 files changed, 80 insertions(+), 71 deletions(-) diff --git a/README.rst b/README.rst index 0d1f625..9d88850 100644 --- a/README.rst +++ b/README.rst @@ -80,6 +80,15 @@ In your tests you can set ``settings.DEBUG = True`` and ``settings.AUTO_CREATE_K By default assumes your test module is ``runtests.py``. You can changes this by setting ``settings.DJANGO_CRYPTO_FIELDS_TEST_MODULE``. +When are encryption keys loaded? +================================ + +The encryption keys are loaded as a side effect of accessing the ``keys`` module. +The keys module is imported in this apps AppConfig just before ``import_models``. +During runtime the encryption keys are stored in the ``encryption_keys`` global. + +See module ``apps.py``, module ``keys.py`` and ``fields.BaseField`` constructor. + History ======= diff --git a/django_crypto_fields/apps.py b/django_crypto_fields/apps.py index 0c26e66..4e15fda 100644 --- a/django_crypto_fields/apps.py +++ b/django_crypto_fields/apps.py @@ -34,3 +34,8 @@ def ready(self): style.WARNING(" * Remember to keep a backup of your encryption keys\n") ) sys.stdout.write(f" Done loading {self.verbose_name}.\n") + + def import_models(self): + from .keys import encryption_keys # noqa + + return super().import_models() diff --git a/django_crypto_fields/cryptor.py b/django_crypto_fields/cryptor.py index 5d31a96..1b96bda 100644 --- a/django_crypto_fields/cryptor.py +++ b/django_crypto_fields/cryptor.py @@ -3,7 +3,6 @@ from Cryptodome import Random from Cryptodome.Cipher import AES as AES_CIPHER from django.conf import settings -from django.core.exceptions import AppRegistryNotReady from .constants import AES, ENCODING, PRIVATE, PUBLIC, RSA from .exceptions import EncryptionError @@ -11,7 +10,7 @@ from .utils import get_keypath_from_settings -class Cryptor(object): +class Cryptor: """Base class for all classes providing RSA and AES encryption methods. @@ -19,7 +18,7 @@ class Cryptor(object): of this except the filenames are replaced with the actual keys. """ - def __init__(self, keys=None, aes_encryption_mode=None): + def __init__(self, aes_encryption_mode=None): self.aes_encryption_mode = aes_encryption_mode if not self.aes_encryption_mode: try: @@ -27,13 +26,9 @@ def __init__(self, keys=None, aes_encryption_mode=None): self.aes_encryption_mode = settings.AES_ENCRYPTION_MODE except AttributeError: self.aes_encryption_mode = AES_CIPHER.MODE_CBC - try: - # ignore "keys" parameter if Django is loaded - self.keys = encryption_keys - except AppRegistryNotReady: - self.keys = keys + self.keys = encryption_keys - def padded(self, plaintext, block_size): + def padded(self, plaintext: str, block_size): """Return string padded so length is a multiple of the block size. * store length of padding the last hex value. * if padding is 0, pad as if padding is 16. diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index fe4308c..ee95f30 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import binascii import hashlib +from typing import TYPE_CHECKING, Type from Cryptodome.Cipher import AES as AES_CIPHER from django.apps import apps as django_apps @@ -27,8 +30,11 @@ from .keys import encryption_keys from .utils import get_crypt_model_cls +if TYPE_CHECKING: + from .models import Crypt + -class FieldCryptor(object): +class FieldCryptor: """Base class for django field classes with encryption. ciphertext = hash_prefix + hashed_value + cipher_prefix + secret @@ -42,7 +48,7 @@ class FieldCryptor(object): crypt_model = "django_crypto_fields.crypt" - def __init__(self, algorithm, mode, keys=None, aes_encryption_mode=None): + def __init__(self, algorithm: str, mode: str, aes_encryption_mode: str | None = None): self._using = None self.algorithm = algorithm self.mode = mode @@ -57,13 +63,13 @@ def __init__(self, algorithm, mode, keys=None, aes_encryption_mode=None): self.aes_encryption_mode = AES_CIPHER.MODE_CBC self.keys = encryption_keys self.cryptor = Cryptor(aes_encryption_mode=self.aes_encryption_mode) - self.hash_size = len(self.hash("Foo")) + self.hash_size: int = len(self.hash("Foo")) - def __repr__(self): + def __repr__(self) -> str: return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.mode}')" @property - def crypt_model_cls(self): + def crypt_model_cls(self) -> Type[Crypt]: """Returns the cipher model and avoids issues with model loading and field classes. """ @@ -133,7 +139,7 @@ def encrypt(self, value, update=None): ) return ciphertext - def decrypt(self, hash_with_prefix): + def decrypt(self, hash_with_prefix: str): """Returns decrypted secret or None. Secret is retrieved from `Crypt` using the hash. @@ -146,8 +152,8 @@ def decrypt(self, hash_with_prefix): except AttributeError: pass if hash_with_prefix: - if self.is_encrypted(hash_with_prefix, has_secret=False): - hashed_value = self.get_hash(hash_with_prefix) + if self.is_encrypted(hash_with_prefix): + # hashed_value = self.get_hash(hash_with_prefix) secret = self.fetch_secret(hash_with_prefix) if secret: if self.algorithm == AES: @@ -162,8 +168,7 @@ def decrypt(self, hash_with_prefix): ) ) else: - hashed_value = self.get_hash(hash_with_prefix) - if hashed_value: + if hashed_value := self.get_hash(hash_with_prefix): raise EncryptionError( 'Failed to decrypt. Could not find "secret" ' f" for hash '{hashed_value}'" @@ -232,22 +237,24 @@ def verify_ciphertext(self, ciphertext): MalformedCiphertextError("Malformed ciphertext.") return ciphertext - def get_prep_value(self, value): + def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: """Returns the prefix + hash as stored in the DB table column of your model's "encrypted" field. - Used by get_prep_value()""" + Used by get_prep_value() + """ if value is None or value in ["", b""]: - return value - ciphertext = self.encrypt(value) - value = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[0] - try: - value.decode() - except AttributeError: - pass - return value # returns into CharField + pass # return None or empty string/byte + else: + ciphertext = self.encrypt(value) + value = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[0] + try: + value.decode() + except AttributeError: + pass + return value - def get_hash(self, ciphertext): + def get_hash(self, ciphertext: bytes | None) -> bytes | None: """Returns the hashed_value given a ciphertext or None.""" try: ciphertext.encode(ENCODING) @@ -255,7 +262,7 @@ def get_hash(self, ciphertext): pass return ciphertext[len(HASH_PREFIX) :][: self.hash_size] or None - def get_secret(self, ciphertext): + def get_secret(self, ciphertext: bytes | None) -> bytes | None: """Returns the secret given a ciphertext.""" if ciphertext is None: secret = None @@ -265,7 +272,7 @@ def get_secret(self, ciphertext): raise CipherError("Expected a ciphertext or None") return secret - def fetch_secret(self, hash_with_prefix): + def fetch_secret(self, hash_with_prefix: bytes): hashed_value = self.get_hash(hash_with_prefix) secret = self.cipher_buffer[self.cipher_buffer_key].get(hashed_value) if not secret: @@ -284,7 +291,7 @@ def fetch_secret(self, hash_with_prefix): ) return secret - def is_encrypted(self, value, has_secret=None): + def is_encrypted(self, value: str | bytes | None) -> bool: """Returns True if value is encrypted. Value can be: * a string value @@ -292,28 +299,21 @@ def is_encrypted(self, value, has_secret=None): * a well-formed hash+secret. """ is_encrypted = False - has_secret = True if has_secret is None else has_secret - if value is None: - is_encrypted = False - else: - is_encrypted = False + if value is not None: try: value = value.encode(ENCODING) except AttributeError: pass - if value[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING) and not value[ - : len(CIPHER_PREFIX) - ] == CIPHER_PREFIX.encode(ENCODING): - value = self.verify_value(value, has_secret=False) - is_encrypted = True - if value[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING) and value[ - : len(CIPHER_PREFIX) - ] == CIPHER_PREFIX.encode(ENCODING): - value = self.verify_value(value, has_secret=True) - is_encrypted = True + if value[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): + if not value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING): + self.verify_value(value, has_secret=False) + is_encrypted = True + elif value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING): + self.verify_value(value, has_secret=True) + is_encrypted = True return is_encrypted - def verify_value(self, value, has_secret=None): + def verify_value(self, value: str | bytes, has_secret=None) -> str | bytes: """Encodes the value, validates its format, and returns it or raises an exception. @@ -346,7 +346,7 @@ def verify_value(self, value, has_secret=None): self.verify_secret(bytes_value) return value # note, is original passed value - def verify_hash(self, ciphertext): + def verify_hash(self, ciphertext: bytes) -> bool: """Verifies hash segment of ciphertext (bytes) and raises an exception if not OK. """ @@ -370,7 +370,8 @@ def verify_hash(self, ciphertext): ) return True - def verify_secret(self, ciphertext): + @staticmethod + def verify_secret(ciphertext: bytes) -> bool: """Verifies secret segment of ciphertext and raises an exception if not OK. """ diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index 12504fc..93f5e6d 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import sys +from typing import TYPE_CHECKING from django.conf import settings from django.core.management.color import color_style @@ -16,31 +19,35 @@ from ..field_cryptor import FieldCryptor from ..keys import encryption_keys +if TYPE_CHECKING: + from ..keys import Keys + style = color_style() class BaseField(models.Field): description = "Field class that stores values as encrypted" - def __init__(self, algorithm, mode, *args, **kwargs): - self.keys = encryption_keys + def __init__(self, algorithm: str, mode: str, *args, **kwargs): + self.readonly = False + self.keys: Keys = encryption_keys if not encryption_keys.loaded: raise DjangoCryptoFieldsKeysNotLoaded( "Encryption keys not loaded. You need to run initialize()" ) self.algorithm = algorithm or RSA self.mode = mode or LOCAL_MODE - self.help_text = kwargs.get("help_text", "") + self.help_text: str = kwargs.get("help_text", "") if not self.help_text.startswith(" (Encryption:"): self.help_text = "{} (Encryption: {} {})".format( self.help_text.split(" (Encryption:")[0], algorithm.upper(), mode ) self.field_cryptor = FieldCryptor(self.algorithm, self.mode) - min_length = len(HASH_PREFIX) + self.field_cryptor.hash_size - max_length = kwargs.get("max_length", min_length) - self.max_length = min_length if max_length < min_length else max_length + min_length: int = len(HASH_PREFIX) + self.field_cryptor.hash_size + max_length: int = kwargs.get("max_length", min_length) + self.max_length: int = min_length if max_length < min_length else max_length if self.algorithm == RSA: - max_message_length = self.keys.rsa_key_info[self.mode]["max_message_length"] + max_message_length: int = self.keys.rsa_key_info[self.mode]["max_message_length"] if self.max_length > max_message_length: raise EncryptionError( "{} attribute 'max_length' cannot exceed {} for RSA. Got {}. " @@ -80,18 +87,15 @@ def decrypt(self, value): self.readonly = True # did not decrypt decrypted_value = value except CipherError as e: - sys.stdout.write(style.ERROR("CipherError. Got {}\n".format(str(e)))) + sys.stdout.write(style.ERROR(f"CipherError. Got {e}\n")) sys.stdout.flush() - # raise ValidationError(e) except EncryptionError as e: - sys.stdout.write(style.ERROR("EncryptionError. Got {}\n".format(str(e)))) + sys.stdout.write(style.ERROR(f"EncryptionError. Got {e}\n")) sys.stdout.flush() raise - # raise ValidationError(e) except MalformedCiphertextError as e: - sys.stdout.write(style.ERROR("MalformedCiphertextError. Got {}\n".format(str(e)))) + sys.stdout.write(style.ERROR(f"MalformedCiphertextError. Got {e}\n")) sys.stdout.flush() - # raise ValidationError(e) return decrypted_value def from_db_value(self, value, *args): @@ -99,11 +103,6 @@ def from_db_value(self, value, *args): return value return self.decrypt(value) - # def to_python(self, value): - # if value is None or value in ['', b'']: - # return value - # return self.decrypt(value) - def get_prep_value(self, value): """Returns the encrypted value, including prefix, as the query value (to query the db). diff --git a/django_crypto_fields/templatetags/crypto_tags.py b/django_crypto_fields/templatetags/crypto_tags.py index be29b5e..410d795 100644 --- a/django_crypto_fields/templatetags/crypto_tags.py +++ b/django_crypto_fields/templatetags/crypto_tags.py @@ -9,6 +9,6 @@ def encrypted(value): retval = value field_cryptor = FieldCryptor("rsa", "local") - if field_cryptor.is_encrypted(value, has_secret=False): + if field_cryptor.is_encrypted(value): retval = field_cryptor.mask(value) return retval diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 14e3ada..9411293 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 01:56:23.839893+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 12:51:27.621760+00:00 From 047c55314c2970d0f51d2b086cd04a765f5a9a24 Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 10:48:57 -0500 Subject: [PATCH 11/25] pin to AES_CIPHER.MODE_CBC, refactor persist_key_path_or_raise, typing hints --- django_crypto_fields/apps.py | 18 ++--- django_crypto_fields/cryptor.py | 64 +++++++++------- django_crypto_fields/exceptions.py | 12 --- django_crypto_fields/field_cryptor.py | 17 ++--- django_crypto_fields/fields/base_aes_field.py | 4 +- django_crypto_fields/fields/base_field.py | 6 +- django_crypto_fields/fields/base_rsa_field.py | 4 +- .../fields/encrypted_decimal_field.py | 2 +- .../fields/encrypted_integer_field.py | 2 +- .../fields/encrypted_text_field.py | 2 +- django_crypto_fields/key_path/__init__.py | 3 +- .../key_path/get_last_key_path.py | 26 ------- .../key_path/persist_key_path_or_raise.py | 71 ++++++++++------- django_crypto_fields/keys/utils.py | 8 +- django_crypto_fields/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/convert_aes_cfb2cbc.py | 76 ------------------- .../tests/crypto_keys/django_crypto_fields | 2 +- django_crypto_fields/utils.py | 4 +- 19 files changed, 105 insertions(+), 216 deletions(-) delete mode 100644 django_crypto_fields/key_path/get_last_key_path.py delete mode 100644 django_crypto_fields/management/__init__.py delete mode 100644 django_crypto_fields/management/commands/__init__.py delete mode 100644 django_crypto_fields/management/commands/convert_aes_cfb2cbc.py diff --git a/django_crypto_fields/apps.py b/django_crypto_fields/apps.py index 4e15fda..80f687f 100644 --- a/django_crypto_fields/apps.py +++ b/django_crypto_fields/apps.py @@ -7,20 +7,17 @@ from django_crypto_fields.key_path import KeyPath -class DjangoCryptoFieldsError(Exception): - pass - - -class DjangoCryptoFieldsKeysDoNotExist(Exception): - pass - - class AppConfig(DjangoAppConfig): name: str = "django_crypto_fields" verbose_name: str = "django-crypto-fields" app_label: str = "django_crypto_fields" crypt_model_using: str = "default" + def import_models(self): + from .keys import encryption_keys # noqa + + return super().import_models() + def ready(self): style = color_style() path = KeyPath().path @@ -34,8 +31,3 @@ def ready(self): style.WARNING(" * Remember to keep a backup of your encryption keys\n") ) sys.stdout.write(f" Done loading {self.verbose_name}.\n") - - def import_models(self): - from .keys import encryption_keys # noqa - - return super().import_models() diff --git a/django_crypto_fields/cryptor.py b/django_crypto_fields/cryptor.py index 1b96bda..2851365 100644 --- a/django_crypto_fields/cryptor.py +++ b/django_crypto_fields/cryptor.py @@ -1,14 +1,22 @@ +from __future__ import annotations + import binascii +from typing import TYPE_CHECKING from Cryptodome import Random from Cryptodome.Cipher import AES as AES_CIPHER -from django.conf import settings from .constants import AES, ENCODING, PRIVATE, PUBLIC, RSA from .exceptions import EncryptionError from .keys import encryption_keys from .utils import get_keypath_from_settings +if TYPE_CHECKING: + from Cryptodome.Cipher._mode_cbc import CbcMode + from Cryptodome.Cipher.PKCS1_OAEP import PKCS1OAEP_Cipher + + from .keys import Keys + class Cryptor: """Base class for all classes providing RSA and AES encryption @@ -18,17 +26,11 @@ class Cryptor: of this except the filenames are replaced with the actual keys. """ - def __init__(self, aes_encryption_mode=None): - self.aes_encryption_mode = aes_encryption_mode - if not self.aes_encryption_mode: - try: - # do not use MODE_CFB, see comments in pycryptodomex.blockalgo.py - self.aes_encryption_mode = settings.AES_ENCRYPTION_MODE - except AttributeError: - self.aes_encryption_mode = AES_CIPHER.MODE_CBC - self.keys = encryption_keys - - def padded(self, plaintext: str, block_size): + def __init__(self): + self.aes_encryption_mode: int = AES_CIPHER.MODE_CBC + self.keys: Keys = encryption_keys + + def get_with_padding(self, plaintext: str | bytes, block_size: int) -> bytes: """Return string padded so length is a multiple of the block size. * store length of padding the last hex value. * if padding is 0, pad as if padding is 16. @@ -58,7 +60,7 @@ def padded(self, plaintext: str, block_size): ) return padded - def unpadded(self, plaintext, block_size): + def get_without_padding(self, plaintext: str | bytes) -> bytes: """Return original plaintext without padding. Length of padding is stored in last two characters of @@ -71,38 +73,42 @@ def unpadded(self, plaintext, block_size): return plaintext[:-1] return plaintext[:-padding_length] - def aes_encrypt(self, plaintext, mode): - aes_key = "_".join([AES, mode, PRIVATE, "key"]) - iv = Random.new().read(AES_CIPHER.block_size) - cipher = AES_CIPHER.new(getattr(self.keys, aes_key), self.aes_encryption_mode, iv) - padded_plaintext = self.padded(plaintext, cipher.block_size) + def aes_encrypt(self, plaintext: str | bytes, mode: str) -> bytes: + aes_key_attr: str = "_".join([AES, mode, PRIVATE, "key"]) + aes_key: bytes = getattr(self.keys, aes_key_attr) + iv: bytes = Random.new().read(AES_CIPHER.block_size) + cipher: CbcMode = AES_CIPHER.new(aes_key, self.aes_encryption_mode, iv) + padded_plaintext = self.get_with_padding(plaintext, cipher.block_size) return iv + cipher.encrypt(padded_plaintext) - def aes_decrypt(self, ciphertext, mode): - aes_key = "_".join([AES, mode, PRIVATE, "key"]) + def aes_decrypt(self, ciphertext: bytes, mode: str) -> str: + aes_key_attr: str = "_".join([AES, mode, PRIVATE, "key"]) + aes_key: bytes = getattr(self.keys, aes_key_attr) iv = ciphertext[: AES_CIPHER.block_size] - cipher = AES_CIPHER.new(getattr(self.keys, aes_key), self.aes_encryption_mode, iv) + cipher: CbcMode = AES_CIPHER.new(aes_key, self.aes_encryption_mode, iv) plaintext = cipher.decrypt(ciphertext)[AES_CIPHER.block_size :] - return self.unpadded(plaintext, cipher.block_size).decode() + return self.get_without_padding(plaintext).decode() - def rsa_encrypt(self, plaintext, mode): - rsa_key = "_".join([RSA, mode, PUBLIC, "key"]) + def rsa_encrypt(self, plaintext: str | bytes, mode: int) -> bytes: + rsa_key_attr = "_".join([RSA, mode, PUBLIC, "key"]) + rsa_key: PKCS1OAEP_Cipher = getattr(self.keys, rsa_key_attr) try: plaintext = plaintext.encode(ENCODING) except AttributeError: pass try: - ciphertext = getattr(self.keys, rsa_key).encrypt(plaintext) + ciphertext = rsa_key.encrypt(plaintext) except (ValueError, TypeError) as e: raise EncryptionError(f"RSA encryption failed for value. Got '{e}'") return ciphertext - def rsa_decrypt(self, ciphertext, mode): - rsa_key = "_".join([RSA, mode, PRIVATE, "key"]) + def rsa_decrypt(self, ciphertext: bytes, mode: str) -> str: + rsa_key_attr = "_".join([RSA, mode, PRIVATE, "key"]) + rsa_key: PKCS1OAEP_Cipher = getattr(self.keys, rsa_key_attr) try: - plaintext = getattr(self.keys, rsa_key).decrypt(ciphertext) + plaintext = rsa_key.decrypt(ciphertext) except ValueError as e: raise EncryptionError( - f"{e} Using {rsa_key} from key_path=`{get_keypath_from_settings()}`." + f"{e} Using {rsa_key_attr} from key_path=`{get_keypath_from_settings()}`." ) return plaintext.decode(ENCODING) diff --git a/django_crypto_fields/exceptions.py b/django_crypto_fields/exceptions.py index 3a51e99..787469b 100644 --- a/django_crypto_fields/exceptions.py +++ b/django_crypto_fields/exceptions.py @@ -22,10 +22,6 @@ class DjangoCryptoFieldsKeysDoNotExist(Exception): pass -class DjangoCryptoFieldsLoadingError(Exception): - pass - - class DjangoCryptoFieldsKeyPathError(Exception): pass @@ -46,14 +42,6 @@ class CipherError(Exception): pass -class AlgorithmError(Exception): - pass - - -class ModeError(Exception): - pass - - class EncryptionKeyError(Exception): pass diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index ee95f30..fc59799 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -6,7 +6,6 @@ from Cryptodome.Cipher import AES as AES_CIPHER from django.apps import apps as django_apps -from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from .constants import ( @@ -48,21 +47,15 @@ class FieldCryptor: crypt_model = "django_crypto_fields.crypt" - def __init__(self, algorithm: str, mode: str, aes_encryption_mode: str | None = None): + def __init__(self, algorithm: str, mode: int): self._using = None self.algorithm = algorithm self.mode = mode - self.aes_encryption_mode = aes_encryption_mode + self.aes_encryption_mode = AES_CIPHER.MODE_CBC self.cipher_buffer_key = f"{self.algorithm}_{self.mode}" self.cipher_buffer = {self.cipher_buffer_key: {}} - if not self.aes_encryption_mode: - try: - # do not use MODE_CFB, see comments in pycryptodomex.blockalgo.py - self.aes_encryption_mode = settings.AES_ENCRYPTION_MODE - except AttributeError: - self.aes_encryption_mode = AES_CIPHER.MODE_CBC self.keys = encryption_keys - self.cryptor = Cryptor(aes_encryption_mode=self.aes_encryption_mode) + self.cryptor = Cryptor() self.hash_size: int = len(self.hash("Foo")) def __repr__(self) -> str: @@ -254,7 +247,7 @@ def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: pass return value - def get_hash(self, ciphertext: bytes | None) -> bytes | None: + def get_hash(self, ciphertext: bytes) -> bytes | None: """Returns the hashed_value given a ciphertext or None.""" try: ciphertext.encode(ENCODING) @@ -262,7 +255,7 @@ def get_hash(self, ciphertext: bytes | None) -> bytes | None: pass return ciphertext[len(HASH_PREFIX) :][: self.hash_size] or None - def get_secret(self, ciphertext: bytes | None) -> bytes | None: + def get_secret(self, ciphertext: bytes) -> bytes | None: """Returns the secret given a ciphertext.""" if ciphertext is None: secret = None diff --git a/django_crypto_fields/fields/base_aes_field.py b/django_crypto_fields/fields/base_aes_field.py index d83d03a..7281d0d 100644 --- a/django_crypto_fields/fields/base_aes_field.py +++ b/django_crypto_fields/fields/base_aes_field.py @@ -5,5 +5,5 @@ class BaseAesField(BaseField): def __init__(self, *args, **kwargs): algorithm = AES - mode = LOCAL_MODE - super().__init__(algorithm, mode, *args, **kwargs) + access_mode = LOCAL_MODE + super().__init__(algorithm, access_mode, *args, **kwargs) diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index 93f5e6d..86810a5 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -28,7 +28,7 @@ class BaseField(models.Field): description = "Field class that stores values as encrypted" - def __init__(self, algorithm: str, mode: str, *args, **kwargs): + def __init__(self, algorithm: str, access_mode: str, *args, **kwargs): self.readonly = False self.keys: Keys = encryption_keys if not encryption_keys.loaded: @@ -36,11 +36,11 @@ def __init__(self, algorithm: str, mode: str, *args, **kwargs): "Encryption keys not loaded. You need to run initialize()" ) self.algorithm = algorithm or RSA - self.mode = mode or LOCAL_MODE + self.mode = access_mode or LOCAL_MODE self.help_text: str = kwargs.get("help_text", "") if not self.help_text.startswith(" (Encryption:"): self.help_text = "{} (Encryption: {} {})".format( - self.help_text.split(" (Encryption:")[0], algorithm.upper(), mode + self.help_text.split(" (Encryption:")[0], algorithm.upper(), self.mode ) self.field_cryptor = FieldCryptor(self.algorithm, self.mode) min_length: int = len(HASH_PREFIX) + self.field_cryptor.hash_size diff --git a/django_crypto_fields/fields/base_rsa_field.py b/django_crypto_fields/fields/base_rsa_field.py index c61a536..cf4c3f6 100644 --- a/django_crypto_fields/fields/base_rsa_field.py +++ b/django_crypto_fields/fields/base_rsa_field.py @@ -5,5 +5,5 @@ class BaseRsaField(BaseField): def __init__(self, *args, **kwargs): algorithm = RSA - mode = LOCAL_MODE - super(BaseRsaField, self).__init__(algorithm, mode, *args, **kwargs) + access_mode = LOCAL_MODE + super().__init__(algorithm, access_mode, *args, **kwargs) diff --git a/django_crypto_fields/fields/encrypted_decimal_field.py b/django_crypto_fields/fields/encrypted_decimal_field.py index 96542a4..383c5ba 100644 --- a/django_crypto_fields/fields/encrypted_decimal_field.py +++ b/django_crypto_fields/fields/encrypted_decimal_field.py @@ -35,7 +35,7 @@ def __init__(self, *args, **kwargs): decimal_max_digits = int(kwargs.get("max_digits")) del kwargs["decimal_places"] del kwargs["max_digits"] - super(EncryptedDecimalField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.decimal_decimal_places = decimal_decimal_places self.decimal_max_digits = decimal_max_digits diff --git a/django_crypto_fields/fields/encrypted_integer_field.py b/django_crypto_fields/fields/encrypted_integer_field.py index def3163..5ce36e9 100644 --- a/django_crypto_fields/fields/encrypted_integer_field.py +++ b/django_crypto_fields/fields/encrypted_integer_field.py @@ -6,6 +6,6 @@ class EncryptedIntegerField(BaseRsaField): def to_python(self, value): """Returns as integer""" - retval = super(EncryptedIntegerField, self).to_python(value) + retval = super().to_python(value) retval = int(retval) return retval diff --git a/django_crypto_fields/fields/encrypted_text_field.py b/django_crypto_fields/fields/encrypted_text_field.py index f7fad61..1d87cce 100644 --- a/django_crypto_fields/fields/encrypted_text_field.py +++ b/django_crypto_fields/fields/encrypted_text_field.py @@ -8,4 +8,4 @@ class EncryptedTextField(BaseAesField): def formfield(self, **kwargs): kwargs["widget"] = widgets.Textarea() - return super(EncryptedTextField, self).formfield(**kwargs) + return super().formfield(**kwargs) diff --git a/django_crypto_fields/key_path/__init__.py b/django_crypto_fields/key_path/__init__.py index 589bd2f..6e1cac7 100644 --- a/django_crypto_fields/key_path/__init__.py +++ b/django_crypto_fields/key_path/__init__.py @@ -1,5 +1,4 @@ -from .get_last_key_path import get_last_key_path from .key_path import KeyPath from .persist_key_path_or_raise import persist_key_path_or_raise -__all__ = ["get_last_key_path", "persist_key_path_or_raise", "KeyPath"] +__all__ = ["persist_key_path_or_raise", "KeyPath"] diff --git a/django_crypto_fields/key_path/get_last_key_path.py b/django_crypto_fields/key_path/get_last_key_path.py deleted file mode 100644 index 2061ce9..0000000 --- a/django_crypto_fields/key_path/get_last_key_path.py +++ /dev/null @@ -1,26 +0,0 @@ -import csv -import sys -from pathlib import Path, PurePath - -from .key_path import KeyPath - -__all__ = ["get_last_key_path"] - - -def get_last_key_path(filename: str | PurePath) -> PurePath | None: - """Get last used DJANGO_CRYPTO_FIELDS_KEY_PATH from - django_crypto_fields file in the key_path folder. - """ - last_used_path: PurePath | None = None - path = Path(KeyPath().path / filename) - if path.exists(): - if "runtests.py" in sys.argv: - path.unlink() # delete the file - else: - with path.open(mode="r") as f: - reader = csv.DictReader(f) - for row in reader: - # use first row only - last_used_path = PurePath(row.get("path")) - break - return last_used_path diff --git a/django_crypto_fields/key_path/persist_key_path_or_raise.py b/django_crypto_fields/key_path/persist_key_path_or_raise.py index 4406738..909708f 100644 --- a/django_crypto_fields/key_path/persist_key_path_or_raise.py +++ b/django_crypto_fields/key_path/persist_key_path_or_raise.py @@ -14,37 +14,15 @@ __all__ = ["persist_key_path_or_raise"] +style = color_style() + def persist_key_path_or_raise() -> None: - last_used_path: PurePath | None = None - path: Path = Path(KeyPath().path) - file = Path(path / "django_crypto_fields") - if file.exists(): - if "runtests.py" in sys.argv: - file.unlink() # delete the file - else: - # open file `django_crypto_fields` and read last path - with file.open(mode="r") as f: - reader = csv.DictReader(f) - for row in reader: - # use first row only - last_used_path = PurePath(row.get("path")) - break - if not last_used_path: - # persist the path in file `django_crypto_fields` - with file.open(mode="w") as f: - writer = csv.DictWriter(f, fieldnames=["path", "date"]) - writer.writeheader() - writer.writerow(dict(path=path, date=datetime.now().astimezone(ZoneInfo("UTC")))) - last_used_path = path - else: - if not Path(last_used_path).exists(): - style = color_style() - raise DjangoCryptoFieldsKeyPathError( - style.ERROR(f"Invalid last key path. See {file}. Got {last_used_path}") - ) - if last_used_path != path: - style = color_style() + expected_folder: Path = Path(KeyPath().path) + last_used_folder, filepath = read_last_used(expected_folder) + if not last_used_folder: + last_used_folder = write_last_used(filepath) + if last_used_folder != expected_folder: raise DjangoCryptoFieldsKeyPathChangeError( style.ERROR( "Key path changed since last startup! You must resolve " @@ -52,3 +30,38 @@ def persist_key_path_or_raise() -> None: "corrupt your data." ) ) + + +def write_last_used(filepath: Path) -> Path: + """Write the last used path in file `django_crypto_fields`.""" + with filepath.open(mode="w") as f: + writer = csv.DictWriter(f, fieldnames=["path", "date"]) + writer.writeheader() + writer.writerow( + dict(path=filepath.parent, date=datetime.now().astimezone(ZoneInfo("UTC"))) + ) + return filepath.parent + + +def read_last_used(folder: Path) -> tuple[PurePath | None, Path]: + """Opens file `django_crypto_fields` and read last path.""" + last_used_path = None + filepath = Path(folder / "django_crypto_fields") + if filepath.exists(): + if "runtests.py" in sys.argv: + filepath.unlink() + else: + with filepath.open(mode="r") as f: + reader = csv.DictReader(f) + for row in reader: + # use first row only + last_used_path = PurePath(row.get("path")) + if not Path(last_used_path).exists(): + raise DjangoCryptoFieldsKeyPathError( + style.ERROR( + "Last path used to access encryption keys is invalid. " + f"See file `{filepath}`. Got `{last_used_path}`" + ) + ) + break + return last_used_path, filepath diff --git a/django_crypto_fields/keys/utils.py b/django_crypto_fields/keys/utils.py index 0b23673..f1ba0d2 100644 --- a/django_crypto_fields/keys/utils.py +++ b/django_crypto_fields/keys/utils.py @@ -47,13 +47,13 @@ def get_template(path: PurePath, key_prefix: str) -> dict[str, dict[str, dict[st def get_filenames(path: PurePath, key_prefix: str) -> list[PurePath]: filenames = [] - for _, v in get_template(path, key_prefix).items(): - for _, _v in v.items(): - for _, filename in _v.items(): + for encryption_mode in get_template(path, key_prefix).values(): + for access_mode in encryption_mode.values(): + for filename in access_mode.values(): filenames.append(filename) return filenames -def write_msg(verbose, msg: str): +def write_msg(verbose, msg: str) -> None: if verbose: sys.stdout.write(msg) diff --git a/django_crypto_fields/management/__init__.py b/django_crypto_fields/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_crypto_fields/management/commands/__init__.py b/django_crypto_fields/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django_crypto_fields/management/commands/convert_aes_cfb2cbc.py b/django_crypto_fields/management/commands/convert_aes_cfb2cbc.py deleted file mode 100644 index fff9f7a..0000000 --- a/django_crypto_fields/management/commands/convert_aes_cfb2cbc.py +++ /dev/null @@ -1,76 +0,0 @@ -import sys - -from Cryptodome.Cipher import AES as AES_CIPHER -from django.apps import apps as django_apps -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from ...constants import AES, LOCAL_MODE -from ...cryptor import Cryptor - - -class Command(BaseCommand): - def add_arguments(self, parser): - parser.add_argument( - "--dry-run", - action="store_true", - dest="dry-run", - default=False, - help="dry run", - ) - - def __init__(self, *args, **kwargs): - self._worklist = {} - self.aes_decrypt = Cryptor(aes_encryption_mode=AES_CIPHER.MODE_CFB).aes_decrypt - self.aes_encrypt = Cryptor(aes_encryption_mode=AES_CIPHER.MODE_CBC).aes_encrypt - super(Command, self).__init__(*args, **kwargs) - - def handle(self, *args, **options): - self.dry_run = options["dry-run"] - if self.dry_run: - sys.stdout.write(self.style.NOTICE("\nDry run. No changes will be made.\n")) - error_msg = ( - "Default encryption mode must be explicitly " - "set to AES.MODE_CFB in settings. " - "(settings.AES_ENCRYPTION_MODE=AES.MODE_CFB)." - ) - try: - if settings.AES_ENCRYPTION_MODE != AES_CIPHER.MODE_CFB: - raise CommandError( - "{} Got '{}'".format(error_msg, settings.AES_ENCRYPTION_MODE) - ) - except AttributeError: - raise CommandError(error_msg) - self.update_crypts() - self.stdout.write("Done.\n") - self.stdout.write( - self.style.NOTICE( - "Important! DO NOT FORGET to remove attribute " - "AES_ENCRYPTION_MODE from settings.py NOW.\n" - ) - ) - - def update_crypts(self): - app = django_apps.get_app_config("django_crypto_fields") - crypts = django_apps.get_model(*app.model).objects.filter( - algorithm=AES, cipher_mode=AES_CIPHER.MODE_CFB - ) - updated = 0 - skipped = 0 - total = crypts.count() - sys.stdout.write("1. Crypt objects: {}\n".format(total)) - for index, obj in enumerate(crypts): - value = self.aes_decrypt(obj.secret, LOCAL_MODE) - if value: - obj.secret = self.aes_encrypt(value, LOCAL_MODE) - obj.cipher_mode = AES_CIPHER.MODE_CBC - if not self.dry_run: - obj.save() - updated += 1 - else: - skipped += 1 - sys.stdout.write(" " + self.msg(total, index + 1, updated, skipped)) - sys.stdout.write("\n") - - def msg(self, total, index, updated, skipped): - return "{index}/{total}. Updated: {updated} Skipped : {skipped}\r" diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 9411293..6e58984 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 12:51:27.621760+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 15:46:31.587285+00:00 diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index c59a509..aabe3d2 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Type from django.apps import apps as django_apps from django.conf import settings @@ -37,7 +37,7 @@ def get_crypt_model() -> str: return getattr(settings, "DJANGO_CRYPTO_FIELDS_MODEL", "django_crypto_fields.crypt") -def get_crypt_model_cls() -> Crypt: +def get_crypt_model_cls() -> Type[Crypt]: """Return the Crypt model that is active in this project.""" try: return django_apps.get_model(get_crypt_model(), require_ready=False) From a461b346647bf0773490f4c73caceae67e2edb43 Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 12:02:27 -0500 Subject: [PATCH 12/25] refactor method decrypt to remove in accessible exceptions --- django_crypto_fields/field_cryptor.py | 67 +++++++++---------- .../tests/crypto_keys/django_crypto_fields | 2 +- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index fc59799..a34b26a 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -47,19 +47,28 @@ class FieldCryptor: crypt_model = "django_crypto_fields.crypt" - def __init__(self, algorithm: str, mode: int): + def __init__(self, algorithm: str, access_mode: str): self._using = None self.algorithm = algorithm - self.mode = mode + self.access_mode = access_mode self.aes_encryption_mode = AES_CIPHER.MODE_CBC - self.cipher_buffer_key = f"{self.algorithm}_{self.mode}" + self.cipher_buffer_key = f"{self.algorithm}_{self.access_mode}" self.cipher_buffer = {self.cipher_buffer_key: {}} self.keys = encryption_keys self.cryptor = Cryptor() self.hash_size: int = len(self.hash("Foo")) def __repr__(self) -> str: - return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.mode}')" + return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.access_mode}')" + + @property + def salt_key(self): + attr = "_".join([SALT, self.access_mode, PRIVATE]) + try: + salt = getattr(self.keys, attr) + except AttributeError as e: + raise EncryptionKeyError(f"Invalid key. Got {attr}. {e}") + return salt @property def crypt_model_cls(self) -> Type[Crypt]: @@ -77,12 +86,7 @@ def hash(self, plaintext): plaintext = plaintext.encode(ENCODING) except AttributeError: pass - attr = "_".join([SALT, self.mode, PRIVATE]) - try: - salt = getattr(self.keys, attr) - except AttributeError as e: - raise EncryptionKeyError(f"Invalid key. Got {attr}. {e}") - dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, plaintext, salt, HASH_ROUNDS) + dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, plaintext, self.salt_key, HASH_ROUNDS) return binascii.hexlify(dk) def encrypt(self, value, update=None): @@ -118,7 +122,7 @@ def encrypt(self, value, update=None): HASH_PREFIX.encode(ENCODING) + self.hash(value) + CIPHER_PREFIX.encode(ENCODING) - + cipher(value, self.mode) + + cipher(value, self.access_mode) ) if update: self.update_crypt(ciphertext) @@ -144,30 +148,19 @@ def decrypt(self, hash_with_prefix: str): hash_with_prefix = hash_with_prefix.encode(ENCODING) except AttributeError: pass - if hash_with_prefix: - if self.is_encrypted(hash_with_prefix): - # hashed_value = self.get_hash(hash_with_prefix) - secret = self.fetch_secret(hash_with_prefix) - if secret: - if self.algorithm == AES: - plaintext = self.cryptor.aes_decrypt(secret, self.mode) - elif self.algorithm == RSA: - plaintext = self.cryptor.rsa_decrypt(secret, self.mode) - else: - raise CipherError( - "Cannot determine algorithm for decryption." - " Valid options are {0}. Got {1}".format( - ", ".join(list(self.keys.key_filenames)), self.algorithm - ) - ) + if self.is_encrypted(hash_with_prefix): + if secret := self.fetch_secret(hash_with_prefix): + if self.algorithm == AES: + plaintext = self.cryptor.aes_decrypt(secret, self.access_mode) + elif self.algorithm == RSA: + plaintext = self.cryptor.rsa_decrypt(secret, self.access_mode) else: - if hashed_value := self.get_hash(hash_with_prefix): - raise EncryptionError( - 'Failed to decrypt. Could not find "secret" ' - f" for hash '{hashed_value}'" + raise CipherError( + "Cannot determine algorithm for decryption." + " Valid options are {0}. Got {1}".format( + ", ".join(list(self.keys.key_filenames)), self.algorithm ) - else: - raise EncryptionError("Failed to decrypt. Malformed ciphertext") + ) return plaintext @property @@ -185,7 +178,7 @@ def update_crypt(self, ciphertext): self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) try: crypt = self.crypt_model_cls.objects.using(self.using).get( - hash=hashed_value, algorithm=self.algorithm, mode=self.mode + hash=hashed_value, algorithm=self.algorithm, mode=self.access_mode ) crypt.secret = secret crypt.save() @@ -195,7 +188,7 @@ def update_crypt(self, ciphertext): secret=secret, algorithm=self.algorithm, cipher_mode=self.aes_encryption_mode, - mode=self.mode, + mode=self.access_mode, ) def verify_ciphertext(self, ciphertext): @@ -273,14 +266,14 @@ def fetch_secret(self, hash_with_prefix: bytes): cipher = ( self.crypt_model_cls.objects.using(self.using) .values("secret") - .get(hash=hashed_value, algorithm=self.algorithm, mode=self.mode) + .get(hash=hashed_value, algorithm=self.algorithm, mode=self.access_mode) ) secret = cipher.get("secret") self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) except ObjectDoesNotExist: raise EncryptionError( f"Failed to get secret for given {self.algorithm} " - f"{self.mode} hash. Got '{hash_with_prefix}'" + f"{self.access_mode} hash. Got '{hash_with_prefix}'" ) return secret diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 6e58984..1b03716 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 15:46:31.587285+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 17:00:56.922566+00:00 From beed0a1d78d29a01e35d02b431bb5eba23c966c2 Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 19:35:38 -0500 Subject: [PATCH 13/25] simplify field_cryptor, move some code to utils, remove unused field class validation from firstname_field --- django_crypto_fields/field_cryptor.py | 170 ++++-------------- django_crypto_fields/fields/__init__.py | 6 +- django_crypto_fields/fields/base_field.py | 4 +- .../fields/firstname_field.py | 40 +---- django_crypto_fields/keys/keys.py | 117 ++++++------ django_crypto_fields/keys/utils.py | 27 ++- django_crypto_fields/models.py | 3 - .../tests/crypto_keys/django_crypto_fields | 2 +- .../tests/tests/test_field_cryptor.py | 39 ++-- django_crypto_fields/utils.py | 88 +++++++++ 10 files changed, 233 insertions(+), 263 deletions(-) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index a34b26a..7a67ab4 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -27,7 +27,7 @@ MalformedCiphertextError, ) from .keys import encryption_keys -from .utils import get_crypt_model_cls +from .utils import get_crypt_model_cls, has_valid_value_or_raise, safe_encode_utf8 if TYPE_CHECKING: from .models import Crypt @@ -82,14 +82,11 @@ def hash(self, plaintext): The hashed value is used as a signature of the "secret". """ - try: - plaintext = plaintext.encode(ENCODING) - except AttributeError: - pass + plaintext = safe_encode_utf8(plaintext) dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, plaintext, self.salt_key, HASH_ROUNDS) return binascii.hexlify(dk) - def encrypt(self, value, update=None): + def encrypt(self, value: str | bytes | None, update: bool | None = None): """Returns ciphertext as byte data using either an RSA or AES cipher. @@ -102,38 +99,13 @@ def encrypt(self, value, update=None): * 'value' is not re-encrypted if already encrypted and properly formatted 'ciphertext'. """ - try: - ciphertext = value.encode(ENCODING) - except AttributeError: - ciphertext = value - if ciphertext is None or value == b"": - pass - else: - update = True if update is None else update - if not self.is_encrypted(value): - try: - if self.algorithm == AES: - cipher = self.cryptor.aes_encrypt - elif self.algorithm == RSA: - cipher = self.cryptor.rsa_encrypt - else: - cipher = None - ciphertext = ( - HASH_PREFIX.encode(ENCODING) - + self.hash(value) - + CIPHER_PREFIX.encode(ENCODING) - + cipher(value, self.access_mode) - ) - if update: - self.update_crypt(ciphertext) - except AttributeError as e: - raise CipherError( - "Cannot determine cipher method. Unknown " - "encryption algorithm. Valid options are {0}. " - "Got {1} ({2})".format( - ", ".join(self.keys.key_filenames), self.algorithm, e - ) - ) + ciphertext = None + update = True if update is None else update + value = safe_encode_utf8(value) + if value is not None and value != b"" and not self.is_encrypted(value): + ciphertext = self.get_ciphertext(value) + if update: + self.update_crypt(ciphertext) return ciphertext def decrypt(self, hash_with_prefix: str): @@ -144,10 +116,7 @@ def decrypt(self, hash_with_prefix: str): hash_with_prefix = hash_prefix+hash. """ plaintext = None - try: - hash_with_prefix = hash_with_prefix.encode(ENCODING) - except AttributeError: - pass + hash_with_prefix = safe_encode_utf8(hash_with_prefix) if self.is_encrypted(hash_with_prefix): if secret := self.fetch_secret(hash_with_prefix): if self.algorithm == AES: @@ -240,12 +209,30 @@ def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: pass return value + def get_ciphertext(self, value): + cipher = None + if self.algorithm == AES: + cipher = self.cryptor.aes_encrypt + elif self.algorithm == RSA: + cipher = self.cryptor.rsa_encrypt + try: + ciphertext = ( + HASH_PREFIX.encode(ENCODING) + + self.hash(value) + + CIPHER_PREFIX.encode(ENCODING) + + cipher(value, self.access_mode) + ) + except AttributeError as e: + raise CipherError( + "Cannot determine cipher method. Unknown " + "encryption algorithm. Valid options are {0}. " + "Got {1} ({2})".format(", ".join(self.keys.key_filenames), self.algorithm, e) + ) + return self.verify_ciphertext(ciphertext) + def get_hash(self, ciphertext: bytes) -> bytes | None: """Returns the hashed_value given a ciphertext or None.""" - try: - ciphertext.encode(ENCODING) - except AttributeError: - pass + ciphertext = safe_encode_utf8(ciphertext) return ciphertext[len(HASH_PREFIX) :][: self.hash_size] or None def get_secret(self, ciphertext: bytes) -> bytes | None: @@ -286,101 +273,16 @@ def is_encrypted(self, value: str | bytes | None) -> bool: """ is_encrypted = False if value is not None: - try: - value = value.encode(ENCODING) - except AttributeError: - pass + value = safe_encode_utf8(value) if value[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): if not value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING): - self.verify_value(value, has_secret=False) + has_valid_value_or_raise(value, self.hash_size, has_secret=False) is_encrypted = True elif value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING): - self.verify_value(value, has_secret=True) + has_valid_value_or_raise(value, self.hash_size, has_secret=True) is_encrypted = True return is_encrypted - def verify_value(self, value: str | bytes, has_secret=None) -> str | bytes: - """Encodes the value, validates its format, and returns it - or raises an exception. - - A value is either a value that can be encrypted or one that - already is encrypted. - - * A value cannot just be equal to HASH_PREFIX or CIPHER_PREFIX; - * A value prefixed with HASH_PREFIX must be followed by a - valid hash (by length); - * A value prefixed with HASH_PREFIX + hashed_value + - CIPHER_PREFIX must be followed by some text; - * A value prefix by CIPHER_PREFIX must be followed by - some text; - """ - has_secret = True if has_secret is None else has_secret - try: - bytes_value = value.encode(ENCODING) - except AttributeError: - bytes_value = value - if bytes_value is not None and bytes_value != b"": - if bytes_value in [ - HASH_PREFIX.encode(ENCODING), - CIPHER_PREFIX.encode(ENCODING), - ]: - raise MalformedCiphertextError( - "Expected a value, got just the encryption prefix." - ) - self.verify_hash(bytes_value) - if has_secret: - self.verify_secret(bytes_value) - return value # note, is original passed value - - def verify_hash(self, ciphertext: bytes) -> bool: - """Verifies hash segment of ciphertext (bytes) and - raises an exception if not OK. - """ - try: - ciphertext = ciphertext.encode(ENCODING) - except AttributeError: - pass - hash_prefix = HASH_PREFIX.encode(ENCODING) - if ciphertext == HASH_PREFIX.encode(ENCODING): - raise MalformedCiphertextError(f"Ciphertext has not hash. Got {ciphertext}") - if not ciphertext[: len(hash_prefix)] == hash_prefix: - raise MalformedCiphertextError( - f"Ciphertext must start with {hash_prefix}. " - f"Got {ciphertext[:len(hash_prefix)]}" - ) - hash_value = ciphertext[len(hash_prefix) :].split(CIPHER_PREFIX.encode(ENCODING))[0] - if len(hash_value) != self.hash_size: - raise MalformedCiphertextError( - "Expected hash prefix to be followed by a hash. " - "Got something else or nothing" - ) - return True - - @staticmethod - def verify_secret(ciphertext: bytes) -> bool: - """Verifies secret segment of ciphertext and raises an - exception if not OK. - """ - if ciphertext[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): - try: - secret = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - if len(secret) == 0: - raise MalformedCiphertextError( - "Expected cipher prefix to be followed by a secret. " "Got nothing (1)" - ) - except IndexError: - raise MalformedCiphertextError( - "Expected cipher prefix to be followed by a secret. " "Got nothing (2)" - ) - if ( - ciphertext[-1 * len(CIPHER_PREFIX) :] == CIPHER_PREFIX.encode(ENCODING) - and len(ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1]) == 0 - ): - raise MalformedCiphertextError( - "Expected cipher prefix to be followed by a secret. " "Got nothing (3)" - ) - return True - def mask(self, value, mask=None): """Returns 'mask' if value is encrypted.""" mask = mask or "" diff --git a/django_crypto_fields/fields/__init__.py b/django_crypto_fields/fields/__init__.py index a779cf2..6a328c8 100644 --- a/django_crypto_fields/fields/__init__.py +++ b/django_crypto_fields/fields/__init__.py @@ -1,7 +1,7 @@ +from .base_aes_field import BaseAesField from .base_field import BaseField +from .base_rsa_field import BaseRsaField from .encrypted_char_field import EncryptedCharField - -# from .encrypted_date_field import EncryptedDateField from .encrypted_decimal_field import EncryptedDecimalField from .encrypted_integer_field import EncryptedIntegerField from .encrypted_text_field import EncryptedTextField @@ -11,6 +11,8 @@ __all__ = [ "BaseField", + "BaseAesField", + "BaseRsaField", "EncryptedCharField", "EncryptedDecimalField", "EncryptedIntegerField", diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index 86810a5..6baa9d6 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -135,7 +135,7 @@ def get_prep_lookup(self, lookup_type, value): self.get_in_as_lookup(value) else: value = HASH_PREFIX.encode(ENCODING) + self.field_cryptor.hash(value) - return super(BaseField, self).get_prep_lookup(lookup_type, value) + return super().get_prep_lookup(lookup_type, value) def get_isnull_as_lookup(self, value): return value @@ -147,7 +147,7 @@ def get_in_as_lookup(self, values): return hashed_values def get_internal_type(self): - """This is a Charfield as we only ever store the hash, + """This is a `CharField` as we only ever store the hash, which is a fixed length char. """ return "CharField" diff --git a/django_crypto_fields/fields/firstname_field.py b/django_crypto_fields/fields/firstname_field.py index ac48116..2dc26bd 100644 --- a/django_crypto_fields/fields/firstname_field.py +++ b/django_crypto_fields/fields/firstname_field.py @@ -1,7 +1,3 @@ -import re - -from django.forms import ValidationError - from .base_rsa_field import BaseRsaField @@ -10,38 +6,4 @@ class FirstnameField(BaseRsaField): attribute. """ - def validate_with_cleaned_data(self, attname, cleaned_data): - if attname in cleaned_data: - first_name = cleaned_data.get(attname, None) - if first_name and "last_name" in cleaned_data and "initials" in cleaned_data: - # check if value is encrypted, if so we need to decrypt it to - # run the tests - if self.is_encrypted(first_name): - self.decrypt(first_name) - if not self.is_encrypted(first_name): - initials = cleaned_data.get("initials", None) - last_name = cleaned_data.get("last_name", None) - if not re.match(r"^[A-Z]{2,3}$", initials): - raise ValidationError( - "Ensure initials are letters (A-Z) in upper case, " - "no spaces or numbers." - ) - self.check_initials_and_firstname(initials, first_name) - self.check_initials_and_lastname(initials, last_name) - - @staticmethod - def check_initials_and_firstname(initials, first_name) -> None: - """Check first and last initial matches first and last name""" - if initials and first_name and first_name[:1].upper() != initials[:1].upper(): - raise ValidationError( - "First initial does not match first name, " - "expected '{}' but you wrote '{}'.".format(first_name[:1], initials[:1]) - ) - - @staticmethod - def check_initials_and_lastname(initials, last_name) -> None: - if initials and last_name and last_name[:1].upper() != initials[-1:].upper(): - raise ValidationError( - "Last initial does not match last name, " - "expected '{}' but you wrote '{}'.".format(last_name[:1], initials[-1:]) - ) + pass diff --git a/django_crypto_fields/keys/keys.py b/django_crypto_fields/keys/keys.py index 25a11b1..445234f 100644 --- a/django_crypto_fields/keys/keys.py +++ b/django_crypto_fields/keys/keys.py @@ -24,7 +24,7 @@ get_key_prefix_from_settings, ) -from .utils import get_filenames, get_template, write_msg +from .utils import get_filenames, get_template, key_files_exist, write_msg style = color_style() @@ -56,7 +56,7 @@ def initialize(self): write_msg(self.verbose, "Loading encryption keys\n") self.keys = deepcopy(self.template) persist_key_path_or_raise() - if not self.key_files_exist: + if not key_files_exist(self.path, self.key_prefix): if auto_create_keys := get_auto_create_keys_from_settings(): if not os.access(self.path, os.W_OK): raise DjangoCryptoFieldsError( @@ -98,7 +98,7 @@ def get(self, k: str): def create(self) -> None: """Generates RSA and AES keys as per `filenames`.""" - if self.key_files_exist: + if key_files_exist(self.path, self.key_prefix): raise DjangoCryptoFieldsKeyAlreadyExist( f"Not creating new keys. Encryption keys already exist. See {self.path}." ) @@ -117,36 +117,36 @@ def load_keys(self) -> None: f"Encryption keys have already been loaded. Path='{self.path}'." ) write_msg(self.verbose, f" * loading keys from {self.path}\n") - for mode, keys in self.keys[RSA].items(): + for access_mode, keys in self.keys[RSA].items(): for key in keys: - write_msg(self.verbose, f" * loading {RSA}.{mode}.{key} ...\r") - self.load_rsa_key(mode, key) - write_msg(self.verbose, f" * loading {RSA}.{mode}.{key} ... Done.\n") - for mode in self.keys[AES]: - write_msg(self.verbose, f" * loading {AES}.{mode} ...\r") - self.load_aes_key(mode) - write_msg(self.verbose, f" * loading {AES}.{mode} ... Done.\n") - for mode in self.keys[SALT]: - write_msg(self.verbose, f" * loading {SALT}.{mode} ...\r") - self.load_salt_key(mode, key) - write_msg(self.verbose, f" * loading {SALT}.{mode} ... Done.\n") + write_msg(self.verbose, f" * loading {RSA}.{access_mode}.{key} ...\r") + self.load_rsa_key(access_mode, key) + write_msg(self.verbose, f" * loading {RSA}.{access_mode}.{key} ... Done.\n") + for access_mode in self.keys[AES]: + write_msg(self.verbose, f" * loading {AES}.{access_mode} ...\r") + self.load_aes_key(access_mode) + write_msg(self.verbose, f" * loading {AES}.{access_mode} ... Done.\n") + for access_mode in self.keys[SALT]: + write_msg(self.verbose, f" * loading {SALT}.{access_mode} ...\r") + self.load_salt_key(access_mode) + write_msg(self.verbose, f" * loading {SALT}.{access_mode} ... Done.\n") self.loaded = True - def load_rsa_key(self, mode, key) -> None: + def load_rsa_key(self, access_mode, key) -> None: """Loads an RSA key into _keys.""" if self.loaded: raise DjangoCryptoFieldsKeysAlreadyLoaded( "Encryption keys have already been loaded." ) - path = Path(self.keys[RSA][mode][key]) + path = Path(self.keys[RSA][access_mode][key]) with path.open(mode="rb") as f: rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) rsa_key = PKCS1_OAEP.new(rsa_key) - self.keys[RSA][mode][key] = rsa_key - self.update_rsa_key_info(rsa_key, mode) - setattr(self, RSA + "_" + mode + "_" + key + "_key", rsa_key) + self.keys[RSA][access_mode][key] = rsa_key + self.update_rsa_key_info(rsa_key, access_mode) + setattr(self, RSA + "_" + access_mode + "_" + key + "_key", rsa_key) - def load_aes_key(self, mode) -> None: + def load_aes_key(self, access_mode: str) -> None: """Decrypts and loads an AES key into _keys. Note: AES does not use a public key. @@ -156,97 +156,82 @@ def load_aes_key(self, mode) -> None: "Encryption keys have already been loaded." ) key = PRIVATE - rsa_key = self.keys[RSA][mode][key] + rsa_key = self.keys[RSA][access_mode][key] try: - path = Path(self.keys[AES][mode][key]) + path = Path(self.keys[AES][access_mode][key]) except KeyError: raise with path.open(mode="rb") as f: aes_key = rsa_key.decrypt(f.read()) - self.keys[AES][mode][key] = aes_key - setattr(self, AES + "_" + mode + "_" + key + "_key", aes_key) + self.keys[AES][access_mode][key] = aes_key + setattr(self, AES + "_" + access_mode + "_" + key + "_key", aes_key) - def load_salt_key(self, mode, key) -> None: + def load_salt_key(self, access_mode: str) -> None: """Decrypts and loads a salt key into _keys.""" if self.loaded: raise DjangoCryptoFieldsKeysAlreadyLoaded( "Encryption keys have already been loaded." ) - attr = SALT + "_" + mode + "_" + PRIVATE - rsa_key = self.keys[RSA][mode][PRIVATE] - path = Path(self.keys[SALT][mode][PRIVATE]) + attr = SALT + "_" + access_mode + "_" + PRIVATE + rsa_key = self.keys[RSA][access_mode][PRIVATE] + path = Path(self.keys[SALT][access_mode][PRIVATE]) with path.open(mode="rb") as f: salt = rsa_key.decrypt(f.read()) setattr(self, attr, salt) - def update_rsa_key_info(self, rsa_key, mode) -> None: + def update_rsa_key_info(self, rsa_key, access_mode: str) -> None: """Stores info about the RSA key.""" if self.loaded: raise DjangoCryptoFieldsKeysAlreadyLoaded( "Encryption keys have already been loaded." ) mod_bits = number.size(rsa_key._key.n) - self.rsa_key_info[mode] = {"bits": mod_bits} + self.rsa_key_info[access_mode] = {"bits": mod_bits} k = number.ceil_div(mod_bits, 8) - self.rsa_key_info[mode].update({"bytes": k}) + self.rsa_key_info[access_mode].update({"bytes": k}) h_len = rsa_key._hashObj.digest_size - self.rsa_key_info[mode].update({"max_message_length": k - (2 * h_len) - 2}) + self.rsa_key_info[access_mode].update({"max_message_length": k - (2 * h_len) - 2}) - def _create_rsa(self, mode=None) -> None: + def _create_rsa(self) -> None: """Creates RSA keys.""" - modes = [mode] if mode else self.keys.get(RSA) - for mode in modes: + for access_mode in self.keys.get(RSA): key = RSA_PUBLIC_KEY.generate(RSA_KEY_SIZE) pub = key.publickey() - path = Path(self.keys.get(RSA).get(mode).get(PUBLIC)) + path = Path(self.keys.get(RSA).get(access_mode).get(PUBLIC)) try: with path.open(mode="xb") as f1: f1.write(pub.exportKey("PEM")) - write_msg(self.verbose, f" - Created new RSA {mode} key {path}\n") - path = Path(self.keys.get(RSA).get(mode).get(PRIVATE)) + write_msg(self.verbose, f" - Created new RSA {access_mode} key {path}\n") + path = Path(self.keys.get(RSA).get(access_mode).get(PRIVATE)) with open(path, "xb") as f2: f2.write(key.exportKey("PEM")) - write_msg(self.verbose, f" - Created new RSA {mode} key {path}\n") + write_msg(self.verbose, f" - Created new RSA {access_mode} key {path}\n") except FileExistsError as e: raise DjangoCryptoFieldsKeyError(f"RSA key already exists. Got {e}") - def _create_aes(self, mode=None) -> None: + def _create_aes(self) -> None: """Creates AES keys and RSA encrypts them.""" - modes = [mode] if mode else self.keys.get(AES) - for mode in modes: - with Path(self.keys.get(RSA).get(mode).get(PUBLIC)).open(mode="rb") as rsa_file: - rsa_key = RSA_PUBLIC_KEY.importKey(rsa_file.read()) + for access_mode in self.keys.get(AES): + with Path(self.keys.get(RSA).get(access_mode).get(PUBLIC)).open(mode="rb") as f: + rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) rsa_key = PKCS1_OAEP.new(rsa_key) aes_key = Random.new().read(16) - path = Path(self.keys.get(AES).get(mode).get(PRIVATE)) + path = Path(self.keys.get(AES).get(access_mode).get(PRIVATE)) with path.open(mode="xb") as f: f.write(rsa_key.encrypt(aes_key)) - write_msg(self.verbose, f" - Created new AES {mode} key {path}\n") + write_msg(self.verbose, f" - Created new AES {access_mode} key {path}\n") - def _create_salt(self, mode=None) -> None: + def _create_salt(self) -> None: """Creates a salt and RSA encrypts it.""" - modes = [mode] if mode else self.keys.get(SALT) - for mode in modes: - with Path(self.keys.get(RSA).get(mode).get(PUBLIC)).open(mode="rb") as rsa_file: - rsa_key = RSA_PUBLIC_KEY.importKey(rsa_file.read()) + for access_mode in self.keys.get(SALT): + with Path(self.keys.get(RSA).get(access_mode).get(PUBLIC)).open(mode="rb") as f: + rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) rsa_key = PKCS1_OAEP.new(rsa_key) salt = Random.new().read(8) - path = Path(self.keys.get(SALT).get(mode).get(PRIVATE)) + path = Path(self.keys.get(SALT).get(access_mode).get(PRIVATE)) with path.open(mode="xb") as f: f.write(rsa_key.encrypt(salt)) - write_msg(self.verbose, f" - Created new salt {mode} key {path}\n") - - @property - def key_files_exist(self) -> bool: - """Return True if any key files exist in the key path.""" - key_files_exist = False - for group, key_group in self.template.items(): - for mode, keys in key_group.items(): - for key in keys: - if Path(self.keys[group][mode][key]).exists(): - key_files_exist = True - break - return key_files_exist + write_msg(self.verbose, f" - Created new salt {access_mode} key {path}\n") encryption_keys = Keys() diff --git a/django_crypto_fields/keys/utils.py b/django_crypto_fields/keys/utils.py index f1ba0d2..636ce6b 100644 --- a/django_crypto_fields/keys/utils.py +++ b/django_crypto_fields/keys/utils.py @@ -1,7 +1,8 @@ from __future__ import annotations import sys -from pathlib import PurePath +from pathlib import Path, PurePath +from typing import Iterator from django_crypto_fields.constants import ( AES, @@ -47,13 +48,29 @@ def get_template(path: PurePath, key_prefix: str) -> dict[str, dict[str, dict[st def get_filenames(path: PurePath, key_prefix: str) -> list[PurePath]: filenames = [] - for encryption_mode in get_template(path, key_prefix).values(): - for access_mode in encryption_mode.values(): - for filename in access_mode.values(): - filenames.append(filename) + for value in get_values_from_nested_dict(get_template(path, key_prefix)): + filenames.append(value) return filenames +def key_files_exist(path: PurePath, key_prefix: str) -> bool: + """Return True if all key files exist in the key path.""" + not_exists = [] + for filename in get_filenames(path, key_prefix): + if not Path(filename).exists(): + not_exists.append(filename) + return len(not_exists) == 0 + + def write_msg(verbose, msg: str) -> None: if verbose: sys.stdout.write(msg) + + +def get_values_from_nested_dict(nested_dict: dict) -> Iterator: + """Recursively traverse nested dictionary to yield values.""" + for key, value in nested_dict.items(): + if isinstance(value, dict): + yield from get_values_from_nested_dict(value) + else: + yield value diff --git a/django_crypto_fields/models.py b/django_crypto_fields/models.py index 52878b9..866de8d 100644 --- a/django_crypto_fields/models.py +++ b/django_crypto_fields/models.py @@ -30,9 +30,6 @@ class Crypt(AuditUuidModelMixin, models.Model): # causes problems with Postgres!! secret = models.BinaryField(verbose_name="Secret") - # secret = models.TextField( - # verbose_name="Secret") - algorithm = models.CharField(max_length=25, db_index=True, null=True) mode = models.CharField(max_length=25, db_index=True, null=True) diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 1b03716..1f6a4e5 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-18 17:00:56.922566+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 00:33:47.301149+00:00 diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py index b908474..b2df4a8 100644 --- a/django_crypto_fields/tests/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -8,6 +8,11 @@ from django_crypto_fields.field_cryptor import FieldCryptor from django_crypto_fields.keys import encryption_keys +from ...utils import ( + has_valid_hash_or_raise, + has_valid_secret_or_raise, + has_valid_value_or_raise, +) from ..models import TestModel @@ -29,11 +34,15 @@ def tearDown(self): def test_can_verify_hash_as_none(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = None - self.assertRaises(TypeError, field_cryptor.verify_hash, value) + self.assertRaises(TypeError, has_valid_hash_or_raise, value, field_cryptor.hash_size) value = "" - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) value = b"" - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) def test_can_verify_hash_not_raises(self): """Assert does NOT raise on valid hash.""" @@ -42,7 +51,7 @@ def test_can_verify_hash_not_raises(self): "Mohammed Ali floats like a butterfly" ) try: - field_cryptor.verify_hash(value) + has_valid_hash_or_raise(value, field_cryptor.hash_size) except MalformedCiphertextError: self.fail("MalformedCiphertextError unexpectedly raised") else: @@ -52,23 +61,29 @@ def test_can_verify_hash_raises(self): """Assert does raise on invalid hash.""" field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = "erik" # missing prefix - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) value = HASH_PREFIX + "blah" # incorrect prefix - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) value = HASH_PREFIX # no hash following prefix - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) def test_verify_with_secret(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") - self.assertTrue(field_cryptor.verify_secret(value)) + self.assertTrue(has_valid_secret_or_raise(value)) def test_raises_on_verify_without_secret(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( "Mohammed Ali floats like a butterfly" ) - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_secret, value) + self.assertRaises(MalformedCiphertextError, has_valid_secret_or_raise, value) def test_verify_is_encrypted(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) @@ -87,9 +102,11 @@ def test_verify_is_not_encrypted(self): def test_verify_value(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = "Mohammed Ali floats like a butterfly" - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_value, value) + self.assertRaises( + MalformedCiphertextError, has_valid_value_or_raise, value, field_cryptor.hash_size + ) value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") - self.assertEqual(value, field_cryptor.verify_value(value)) + self.assertEqual(value, has_valid_value_or_raise(value, field_cryptor.hash_size)) def test_rsa_field_encryption(self): """Assert successful RSA field roundtrip.""" diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index aabe3d2..7226898 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -7,6 +7,9 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from .constants import CIPHER_PREFIX, ENCODING, HASH_PREFIX +from .exceptions import MalformedCiphertextError + if TYPE_CHECKING: from django.db import models @@ -77,3 +80,88 @@ def get_test_module_from_settings() -> str: def get_key_prefix_from_settings() -> str: return getattr(settings, "DJANGO_CRYPTO_FIELDS_KEY_PREFIX", "user") + + +def safe_encode_utf8(value) -> bytes: + try: + value = value.encode(ENCODING) + except AttributeError: + pass + return value + + +def has_valid_secret_or_raise(ciphertext: bytes) -> bool: + """Verifies secret segment of ciphertext and raises an + exception if not OK. + """ + if ciphertext[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): + try: + secret = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] + if len(secret) == 0: + raise MalformedCiphertextError( + "Expected cipher prefix to be followed by a secret. " "Got nothing (1)" + ) + except IndexError: + raise MalformedCiphertextError( + "Expected cipher prefix to be followed by a secret. " "Got nothing (2)" + ) + if ( + ciphertext[-1 * len(CIPHER_PREFIX) :] == CIPHER_PREFIX.encode(ENCODING) + and len(ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1]) == 0 + ): + raise MalformedCiphertextError( + "Expected cipher prefix to be followed by a secret. " "Got nothing (3)" + ) + return True + + +def has_valid_hash_or_raise(ciphertext: bytes, hash_size: int) -> bool: + """Verifies hash segment of ciphertext (bytes) and + raises an exception if not OK. + """ + ciphertext = safe_encode_utf8(ciphertext) + hash_prefix = HASH_PREFIX.encode(ENCODING) + if ciphertext == HASH_PREFIX.encode(ENCODING): + raise MalformedCiphertextError(f"Ciphertext has not hash. Got {ciphertext}") + if not ciphertext[: len(hash_prefix)] == hash_prefix: + raise MalformedCiphertextError( + f"Ciphertext must start with {hash_prefix}. " + f"Got {ciphertext[:len(hash_prefix)]}" + ) + hash_value = ciphertext[len(hash_prefix) :].split(CIPHER_PREFIX.encode(ENCODING))[0] + if len(hash_value) != hash_size: + raise MalformedCiphertextError( + "Expected hash prefix to be followed by a hash. Got something else or nothing" + ) + return True + + +def has_valid_value_or_raise( + value: str | bytes, hash_size: int, has_secret=None +) -> str | bytes: + """Encodes the value, validates its format, and returns it + or raises an exception. + + A value is either a value that can be encrypted or one that + already is encrypted. + + * A value cannot just be equal to HASH_PREFIX or CIPHER_PREFIX; + * A value prefixed with HASH_PREFIX must be followed by a + valid hash (by length); + * A value prefixed with HASH_PREFIX + hashed_value + + CIPHER_PREFIX must be followed by some text; + * A value prefix by CIPHER_PREFIX must be followed by + some text; + """ + has_secret = True if has_secret is None else has_secret + bytes_value = safe_encode_utf8(value) + if bytes_value is not None and bytes_value != b"": + if bytes_value in [ + HASH_PREFIX.encode(ENCODING), + CIPHER_PREFIX.encode(ENCODING), + ]: + raise MalformedCiphertextError("Expected a value, got just the encryption prefix.") + has_valid_hash_or_raise(bytes_value, hash_size) + if has_secret: + has_valid_secret_or_raise(bytes_value) + return value # note, is original passed value From 01a5109bd53b564893e45646dd1746704cd13aa3 Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 21:12:24 -0500 Subject: [PATCH 14/25] add missing raise, simplify conditional when checking is_encrypted --- django_crypto_fields/field_cryptor.py | 13 +-- django_crypto_fields/keys/keys.py | 92 ++++++++----------- .../tests/crypto_keys/django_crypto_fields | 2 +- django_crypto_fields/utils.py | 10 +- 4 files changed, 51 insertions(+), 66 deletions(-) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index 7a67ab4..dc105ab 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -167,11 +167,11 @@ def verify_ciphertext(self, ciphertext): try: ciphertext.split(HASH_PREFIX.encode(ENCODING))[1] except IndexError: - ValueError(f"Malformed ciphertext. Expected prefixes {HASH_PREFIX}") + raise ValueError(f"Malformed ciphertext. Expected prefixes {HASH_PREFIX}") try: ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] except IndexError: - ValueError(f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}") + raise ValueError(f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}") try: if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): raise MalformedCiphertextError( @@ -275,12 +275,9 @@ def is_encrypted(self, value: str | bytes | None) -> bool: if value is not None: value = safe_encode_utf8(value) if value[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): - if not value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING): - has_valid_value_or_raise(value, self.hash_size, has_secret=False) - is_encrypted = True - elif value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING): - has_valid_value_or_raise(value, self.hash_size, has_secret=True) - is_encrypted = True + has_secret = value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING) + has_valid_value_or_raise(value, self.hash_size, has_secret=has_secret) + is_encrypted = True return is_encrypted def mask(self, value, mask=None): diff --git a/django_crypto_fields/keys/keys.py b/django_crypto_fields/keys/keys.py index 445234f..5eeb70b 100644 --- a/django_crypto_fields/keys/keys.py +++ b/django_crypto_fields/keys/keys.py @@ -112,72 +112,60 @@ def create(self) -> None: def load_keys(self) -> None: """Loads all keys defined in self.filenames.""" + write_msg(self.verbose, f" * loading keys from {self.path}\n") if self.loaded: raise DjangoCryptoFieldsKeysAlreadyLoaded( f"Encryption keys have already been loaded. Path='{self.path}'." ) - write_msg(self.verbose, f" * loading keys from {self.path}\n") + self.load_rsa_keys() + self.load_aes_keys() + self.load_salt_keys() + self.loaded = True + + def load_rsa_keys(self) -> None: + """Loads RSA keys into _keys.""" for access_mode, keys in self.keys[RSA].items(): for key in keys: write_msg(self.verbose, f" * loading {RSA}.{access_mode}.{key} ...\r") - self.load_rsa_key(access_mode, key) + path = Path(self.keys[RSA][access_mode][key]) + with path.open(mode="rb") as f: + rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) + rsa_key = PKCS1_OAEP.new(rsa_key) + self.keys[RSA][access_mode][key] = rsa_key + self.update_rsa_key_info(rsa_key, access_mode) + setattr(self, RSA + "_" + access_mode + "_" + key + "_key", rsa_key) write_msg(self.verbose, f" * loading {RSA}.{access_mode}.{key} ... Done.\n") + + def load_aes_keys(self) -> None: + """Decrypts and loads AES keys into _keys. + + Note: AES does not use a public key. + """ + key = PRIVATE for access_mode in self.keys[AES]: write_msg(self.verbose, f" * loading {AES}.{access_mode} ...\r") - self.load_aes_key(access_mode) + rsa_key = self.keys[RSA][access_mode][key] + try: + path = Path(self.keys[AES][access_mode][key]) + except KeyError: + raise + with path.open(mode="rb") as f: + aes_key = rsa_key.decrypt(f.read()) + self.keys[AES][access_mode][key] = aes_key + setattr(self, AES + "_" + access_mode + "_" + key + "_key", aes_key) write_msg(self.verbose, f" * loading {AES}.{access_mode} ... Done.\n") + + def load_salt_keys(self) -> None: + """Decrypts and loads salt keys into _keys.""" for access_mode in self.keys[SALT]: write_msg(self.verbose, f" * loading {SALT}.{access_mode} ...\r") - self.load_salt_key(access_mode) + attr = SALT + "_" + access_mode + "_" + PRIVATE + rsa_key = self.keys[RSA][access_mode][PRIVATE] + path = Path(self.keys[SALT][access_mode][PRIVATE]) + with path.open(mode="rb") as f: + salt = rsa_key.decrypt(f.read()) + setattr(self, attr, salt) write_msg(self.verbose, f" * loading {SALT}.{access_mode} ... Done.\n") - self.loaded = True - - def load_rsa_key(self, access_mode, key) -> None: - """Loads an RSA key into _keys.""" - if self.loaded: - raise DjangoCryptoFieldsKeysAlreadyLoaded( - "Encryption keys have already been loaded." - ) - path = Path(self.keys[RSA][access_mode][key]) - with path.open(mode="rb") as f: - rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) - rsa_key = PKCS1_OAEP.new(rsa_key) - self.keys[RSA][access_mode][key] = rsa_key - self.update_rsa_key_info(rsa_key, access_mode) - setattr(self, RSA + "_" + access_mode + "_" + key + "_key", rsa_key) - - def load_aes_key(self, access_mode: str) -> None: - """Decrypts and loads an AES key into _keys. - - Note: AES does not use a public key. - """ - if self.loaded: - raise DjangoCryptoFieldsKeysAlreadyLoaded( - "Encryption keys have already been loaded." - ) - key = PRIVATE - rsa_key = self.keys[RSA][access_mode][key] - try: - path = Path(self.keys[AES][access_mode][key]) - except KeyError: - raise - with path.open(mode="rb") as f: - aes_key = rsa_key.decrypt(f.read()) - self.keys[AES][access_mode][key] = aes_key - setattr(self, AES + "_" + access_mode + "_" + key + "_key", aes_key) - - def load_salt_key(self, access_mode: str) -> None: - """Decrypts and loads a salt key into _keys.""" - if self.loaded: - raise DjangoCryptoFieldsKeysAlreadyLoaded( - "Encryption keys have already been loaded." - ) - attr = SALT + "_" + access_mode + "_" + PRIVATE - rsa_key = self.keys[RSA][access_mode][PRIVATE] - path = Path(self.keys[SALT][access_mode][PRIVATE]) - with path.open(mode="rb") as f: - salt = rsa_key.decrypt(f.read()) - setattr(self, attr, salt) def update_rsa_key_info(self, rsa_key, access_mode: str) -> None: """Stores info about the RSA key.""" diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 1f6a4e5..e7207b0 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 00:33:47.301149+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 01:06:53.120948+00:00 diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 7226898..8188633 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -154,14 +154,14 @@ def has_valid_value_or_raise( some text; """ has_secret = True if has_secret is None else has_secret - bytes_value = safe_encode_utf8(value) - if bytes_value is not None and bytes_value != b"": - if bytes_value in [ + encoded_value = safe_encode_utf8(value) + if encoded_value is not None and encoded_value != b"": + if encoded_value in [ HASH_PREFIX.encode(ENCODING), CIPHER_PREFIX.encode(ENCODING), ]: raise MalformedCiphertextError("Expected a value, got just the encryption prefix.") - has_valid_hash_or_raise(bytes_value, hash_size) + has_valid_hash_or_raise(encoded_value, hash_size) if has_secret: - has_valid_secret_or_raise(bytes_value) + has_valid_secret_or_raise(encoded_value) return value # note, is original passed value From e2b85490a438af4ab93cbb2a3faa178b537c8b8b Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 21:14:55 -0500 Subject: [PATCH 15/25] CHANGES --- CHANGES | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index e4e046c..63c171f 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,7 @@ CHANGES simplify and refactor. - refactor KeyPath - initialize / create encryption_keys in constructor of Keys class. +- load keys before import_models from AppConfig - set Keys instance in keys module and import from there instead of from AppConfig. - name global Keys instance 'encryption_keys'. @@ -15,7 +16,7 @@ CHANGES settings.DJANGO_CRYPTO_FIELDS_AUTO_CREATE. (settings.AUTO_CREATE_KEYS will still work) - use pathlib instead of os -- remove system checks, raise exceptions when Keys is instantiated. +- remove system checks, instead raise exceptions when Keys is instantiated. 0.3.10 ------ From aef42d8534a67b1480f84ea34f2ff46478589997 Mon Sep 17 00:00:00 2001 From: Erik van Widenfelt Date: Mon, 18 Mar 2024 21:19:21 -0500 Subject: [PATCH 16/25] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 9d88850..780b8b8 100644 --- a/README.rst +++ b/README.rst @@ -137,7 +137,7 @@ Contribute .. |downloads| image:: https://pepy.tech/badge/django-crypto-fields :target: https://pepy.tech/project/django-crypto-fields -.. |maintainability| image:: https://api.codeclimate.com/v1/badges/e08f2bbee238af7bfdc7/maintainability +.. |maintainability| image:: https://api.codeclimate.com/v1/badges/34293a3ec19da8d7fb16/maintainability :target: https://codeclimate.com/github/erikvw/django-crypto-fields/maintainability :alt: Maintainability From 0e54b13d5afe3a566f61741080a860ad057db339 Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 21:54:41 -0500 Subject: [PATCH 17/25] merge similar logic of verify_ciphertext and has_valid_secret_or_raise into single func --- django_crypto_fields/field_cryptor.py | 50 +++------------ .../key_path/persist_key_path_or_raise.py | 15 ++--- .../tests/crypto_keys/django_crypto_fields | 2 +- .../tests/tests/test_field_cryptor.py | 11 +++- django_crypto_fields/utils.py | 64 +++++++++++-------- 5 files changed, 63 insertions(+), 79 deletions(-) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index dc105ab..b5c54a4 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -20,14 +20,14 @@ SALT, ) from .cryptor import Cryptor -from .exceptions import ( - CipherError, - EncryptionError, - EncryptionKeyError, - MalformedCiphertextError, -) +from .exceptions import CipherError, EncryptionError, EncryptionKeyError from .keys import encryption_keys -from .utils import get_crypt_model_cls, has_valid_value_or_raise, safe_encode_utf8 +from .utils import ( + get_crypt_model_cls, + has_valid_value_or_raise, + is_valid_ciphertext_or_raise, + safe_encode_utf8, +) if TYPE_CHECKING: from .models import Crypt @@ -141,7 +141,7 @@ def using(self): def update_crypt(self, ciphertext): """Updates cipher model (Crypt) and temporary buffer.""" - if self.verify_ciphertext(ciphertext): + if is_valid_ciphertext_or_raise(ciphertext, self.hash_size): hashed_value = self.get_hash(ciphertext) secret = self.get_secret(ciphertext) self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) @@ -160,38 +160,6 @@ def update_crypt(self, ciphertext): mode=self.access_mode, ) - def verify_ciphertext(self, ciphertext): - """Returns ciphertext after verifying format prefix + - hash + prefix + secret. - """ - try: - ciphertext.split(HASH_PREFIX.encode(ENCODING))[1] - except IndexError: - raise ValueError(f"Malformed ciphertext. Expected prefixes {HASH_PREFIX}") - try: - ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - except IndexError: - raise ValueError(f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}") - try: - if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected hash prefix {HASH_PREFIX}" - ) - if ( - len( - ciphertext.split(HASH_PREFIX.encode(ENCODING))[1].split( - CIPHER_PREFIX.encode(ENCODING) - )[0] - ) - != self.hash_size - ): - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected hash size of {self.hash_size}." - ) - except IndexError: - MalformedCiphertextError("Malformed ciphertext.") - return ciphertext - def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: """Returns the prefix + hash as stored in the DB table column of your model's "encrypted" field. @@ -228,7 +196,7 @@ def get_ciphertext(self, value): "encryption algorithm. Valid options are {0}. " "Got {1} ({2})".format(", ".join(self.keys.key_filenames), self.algorithm, e) ) - return self.verify_ciphertext(ciphertext) + return is_valid_ciphertext_or_raise(ciphertext, self.hash_size) def get_hash(self, ciphertext: bytes) -> bytes | None: """Returns the hashed_value given a ciphertext or None.""" diff --git a/django_crypto_fields/key_path/persist_key_path_or_raise.py b/django_crypto_fields/key_path/persist_key_path_or_raise.py index 909708f..9c82efa 100644 --- a/django_crypto_fields/key_path/persist_key_path_or_raise.py +++ b/django_crypto_fields/key_path/persist_key_path_or_raise.py @@ -54,14 +54,13 @@ def read_last_used(folder: Path) -> tuple[PurePath | None, Path]: with filepath.open(mode="r") as f: reader = csv.DictReader(f) for row in reader: - # use first row only last_used_path = PurePath(row.get("path")) - if not Path(last_used_path).exists(): - raise DjangoCryptoFieldsKeyPathError( - style.ERROR( - "Last path used to access encryption keys is invalid. " - f"See file `{filepath}`. Got `{last_used_path}`" - ) - ) break + if last_used_path and not Path(last_used_path).exists(): + raise DjangoCryptoFieldsKeyPathError( + style.ERROR( + "Last path used to access encryption keys is invalid. " + f"See file `{filepath}`. Got `{last_used_path}`" + ) + ) return last_used_path, filepath diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index e7207b0..95d9b84 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 01:06:53.120948+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 02:51:34.544731+00:00 diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py index b2df4a8..0ee10df 100644 --- a/django_crypto_fields/tests/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -10,8 +10,8 @@ from ...utils import ( has_valid_hash_or_raise, - has_valid_secret_or_raise, has_valid_value_or_raise, + is_valid_ciphertext_or_raise, ) from ..models import TestModel @@ -76,14 +76,19 @@ def test_can_verify_hash_raises(self): def test_verify_with_secret(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") - self.assertTrue(has_valid_secret_or_raise(value)) + self.assertTrue(is_valid_ciphertext_or_raise(value, field_cryptor.hash_size)) def test_raises_on_verify_without_secret(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( "Mohammed Ali floats like a butterfly" ) - self.assertRaises(MalformedCiphertextError, has_valid_secret_or_raise, value) + self.assertRaises( + MalformedCiphertextError, + is_valid_ciphertext_or_raise, + value, + field_cryptor.hash_size, + ) def test_verify_is_encrypted(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 8188633..292a984 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -90,31 +90,6 @@ def safe_encode_utf8(value) -> bytes: return value -def has_valid_secret_or_raise(ciphertext: bytes) -> bool: - """Verifies secret segment of ciphertext and raises an - exception if not OK. - """ - if ciphertext[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): - try: - secret = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - if len(secret) == 0: - raise MalformedCiphertextError( - "Expected cipher prefix to be followed by a secret. " "Got nothing (1)" - ) - except IndexError: - raise MalformedCiphertextError( - "Expected cipher prefix to be followed by a secret. " "Got nothing (2)" - ) - if ( - ciphertext[-1 * len(CIPHER_PREFIX) :] == CIPHER_PREFIX.encode(ENCODING) - and len(ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1]) == 0 - ): - raise MalformedCiphertextError( - "Expected cipher prefix to be followed by a secret. " "Got nothing (3)" - ) - return True - - def has_valid_hash_or_raise(ciphertext: bytes, hash_size: int) -> bool: """Verifies hash segment of ciphertext (bytes) and raises an exception if not OK. @@ -163,5 +138,42 @@ def has_valid_value_or_raise( raise MalformedCiphertextError("Expected a value, got just the encryption prefix.") has_valid_hash_or_raise(encoded_value, hash_size) if has_secret: - has_valid_secret_or_raise(encoded_value) + is_valid_ciphertext_or_raise(encoded_value, hash_size) return value # note, is original passed value + + +def is_valid_ciphertext_or_raise(ciphertext: bytes, hash_size: int): + """Returns an unchanged ciphertext after verifying format cipher_prefix + + hash + cipher_prefix + secret. + """ + try: + ciphertext.split(HASH_PREFIX.encode(ENCODING))[1] + except IndexError: + raise MalformedCiphertextError( + f"Malformed ciphertext. Expected prefixes {HASH_PREFIX}" + ) + try: + ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] + except IndexError: + raise MalformedCiphertextError( + f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}" + ) + try: + if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): + raise MalformedCiphertextError( + f"Malformed ciphertext. Expected hash prefix {HASH_PREFIX}" + ) + if ( + len( + ciphertext.split(HASH_PREFIX.encode(ENCODING))[1].split( + CIPHER_PREFIX.encode(ENCODING) + )[0] + ) + != hash_size + ): + raise MalformedCiphertextError( + f"Malformed ciphertext. Expected hash size of {hash_size}." + ) + except IndexError: + MalformedCiphertextError("Malformed ciphertext.") + return ciphertext From 23471d7910b0246baa8733aeb04271d4d3ab24cc Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 22:29:52 -0500 Subject: [PATCH 18/25] split keys.reset into keys.reset and keys.reset_and_delete_keys --- django_crypto_fields/keys/keys.py | 57 ++++++++++--------- .../tests/crypto_keys/django_crypto_fields | 2 +- .../tests/tests/test_cryptor.py | 10 +--- .../tests/tests/test_field_cryptor.py | 10 +--- django_crypto_fields/tests/tests/test_keys.py | 20 +++---- .../tests/tests/test_models.py | 10 +--- 6 files changed, 45 insertions(+), 64 deletions(-) diff --git a/django_crypto_fields/keys/keys.py b/django_crypto_fields/keys/keys.py index 5eeb70b..3d4120f 100644 --- a/django_crypto_fields/keys/keys.py +++ b/django_crypto_fields/keys/keys.py @@ -48,7 +48,7 @@ def __init__(self, verbose: bool = None): self.aes_modes_supported = None self.path = KeyPath().path self.template = get_template(self.path, self.key_prefix) - self.files = get_filenames(self.path, self.key_prefix) + self.filenames = get_filenames(self.path, self.key_prefix) self.initialize() def initialize(self): @@ -78,20 +78,22 @@ def initialize(self): self.load_keys() self.rsa_modes_supported = sorted([k for k in self.keys[RSA]]) self.aes_modes_supported = sorted([k for k in self.keys[AES]]) - write_msg(self.verbose, " Done loading encryption keys\n") - def reset(self, delete_all_keys: str = None, verbose: bool = None): - """Use with extreme care!""" - verbose = self.verbose if verbose is None else verbose + def reset(self): + """For use in tests.""" self.keys = deepcopy(self.template) self.loaded = False - if delete_all_keys == "delete_all_keys": - write_msg(verbose, style.ERROR(" * Deleting encryption keys\n")) - for file in encryption_keys.files: - try: - Path(file).unlink() - except FileNotFoundError: - pass + + def reset_and_delete_keys(self, verbose: bool | None = None): + """For use in tests. + + Use with extreme care! + """ + verbose = self.verbose if verbose is None else verbose + self.reset() + write_msg(verbose, style.ERROR(" * Deleting encryption keys\n")) + for filename in self.filenames: + Path(filename).unlink(missing_ok=True) def get(self, k: str): return self.keys.get(k) @@ -106,13 +108,15 @@ def create(self) -> None: self._create_rsa() self._create_aes() self._create_salt() - write_msg(self.verbose, " Done generating new encryption keys.\n") - write_msg(self.verbose, f" Your new encryption keys are in {self.path}.\n") - write_msg(self.verbose, style.ERROR(" DON'T FORGET TO BACKUP YOUR NEW KEYS!!\n")) + write_msg(self.verbose, f" Your new encryption keys are in {self.path}.\n") + write_msg(self.verbose, style.ERROR(" DON'T FORGET TO BACKUP YOUR NEW KEYS!!\n")) + write_msg(self.verbose, " Done generating new encryption keys.\n") def load_keys(self) -> None: """Loads all keys defined in self.filenames.""" - write_msg(self.verbose, f" * loading keys from {self.path}\n") + write_msg( + self.verbose, style.WARNING(f" * Loading encryption keys from {self.path}\n") + ) if self.loaded: raise DjangoCryptoFieldsKeysAlreadyLoaded( f"Encryption keys have already been loaded. Path='{self.path}'." @@ -121,12 +125,13 @@ def load_keys(self) -> None: self.load_aes_keys() self.load_salt_keys() self.loaded = True + write_msg(self.verbose, " Done loading encryption keys\n") def load_rsa_keys(self) -> None: """Loads RSA keys into _keys.""" for access_mode, keys in self.keys[RSA].items(): for key in keys: - write_msg(self.verbose, f" * loading {RSA}.{access_mode}.{key} ...\r") + write_msg(self.verbose, f" - loading {RSA}.{access_mode}.{key} ...\r") path = Path(self.keys[RSA][access_mode][key]) with path.open(mode="rb") as f: rsa_key = RSA_PUBLIC_KEY.importKey(f.read()) @@ -134,7 +139,7 @@ def load_rsa_keys(self) -> None: self.keys[RSA][access_mode][key] = rsa_key self.update_rsa_key_info(rsa_key, access_mode) setattr(self, RSA + "_" + access_mode + "_" + key + "_key", rsa_key) - write_msg(self.verbose, f" * loading {RSA}.{access_mode}.{key} ... Done.\n") + write_msg(self.verbose, f" - loading {RSA}.{access_mode}.{key} ... Done.\n") def load_aes_keys(self) -> None: """Decrypts and loads AES keys into _keys. @@ -143,7 +148,7 @@ def load_aes_keys(self) -> None: """ key = PRIVATE for access_mode in self.keys[AES]: - write_msg(self.verbose, f" * loading {AES}.{access_mode} ...\r") + write_msg(self.verbose, f" - loading {AES}.{access_mode} ...\r") rsa_key = self.keys[RSA][access_mode][key] try: path = Path(self.keys[AES][access_mode][key]) @@ -153,19 +158,19 @@ def load_aes_keys(self) -> None: aes_key = rsa_key.decrypt(f.read()) self.keys[AES][access_mode][key] = aes_key setattr(self, AES + "_" + access_mode + "_" + key + "_key", aes_key) - write_msg(self.verbose, f" * loading {AES}.{access_mode} ... Done.\n") + write_msg(self.verbose, f" - loading {AES}.{access_mode} ... Done.\n") def load_salt_keys(self) -> None: """Decrypts and loads salt keys into _keys.""" for access_mode in self.keys[SALT]: - write_msg(self.verbose, f" * loading {SALT}.{access_mode} ...\r") + write_msg(self.verbose, f" - loading {SALT}.{access_mode} ...\r") attr = SALT + "_" + access_mode + "_" + PRIVATE rsa_key = self.keys[RSA][access_mode][PRIVATE] path = Path(self.keys[SALT][access_mode][PRIVATE]) with path.open(mode="rb") as f: salt = rsa_key.decrypt(f.read()) setattr(self, attr, salt) - write_msg(self.verbose, f" * loading {SALT}.{access_mode} ... Done.\n") + write_msg(self.verbose, f" - loading {SALT}.{access_mode} ... Done.\n") def update_rsa_key_info(self, rsa_key, access_mode: str) -> None: """Stores info about the RSA key.""" @@ -189,11 +194,11 @@ def _create_rsa(self) -> None: try: with path.open(mode="xb") as f1: f1.write(pub.exportKey("PEM")) - write_msg(self.verbose, f" - Created new RSA {access_mode} key {path}\n") + write_msg(self.verbose, f" - Created new RSA {access_mode} key {path}\n") path = Path(self.keys.get(RSA).get(access_mode).get(PRIVATE)) with open(path, "xb") as f2: f2.write(key.exportKey("PEM")) - write_msg(self.verbose, f" - Created new RSA {access_mode} key {path}\n") + write_msg(self.verbose, f" - Created new RSA {access_mode} key {path}\n") except FileExistsError as e: raise DjangoCryptoFieldsKeyError(f"RSA key already exists. Got {e}") @@ -207,7 +212,7 @@ def _create_aes(self) -> None: path = Path(self.keys.get(AES).get(access_mode).get(PRIVATE)) with path.open(mode="xb") as f: f.write(rsa_key.encrypt(aes_key)) - write_msg(self.verbose, f" - Created new AES {access_mode} key {path}\n") + write_msg(self.verbose, f" - Created new AES {access_mode} key {path}\n") def _create_salt(self) -> None: """Creates a salt and RSA encrypts it.""" @@ -219,7 +224,7 @@ def _create_salt(self) -> None: path = Path(self.keys.get(SALT).get(access_mode).get(PRIVATE)) with path.open(mode="xb") as f: f.write(rsa_key.encrypt(salt)) - write_msg(self.verbose, f" - Created new salt {access_mode} key {path}\n") + write_msg(self.verbose, f" - Created new salt {access_mode} key {path}\n") encryption_keys = Keys() diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 95d9b84..5c39363 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 02:51:34.544731+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 03:29:04.747127+00:00 diff --git a/django_crypto_fields/tests/tests/test_cryptor.py b/django_crypto_fields/tests/tests/test_cryptor.py index 9925ffd..d911797 100644 --- a/django_crypto_fields/tests/tests/test_cryptor.py +++ b/django_crypto_fields/tests/tests/test_cryptor.py @@ -10,18 +10,12 @@ class TestCryptor(TestCase): def setUp(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) encryption_keys.verbose = False encryption_keys.initialize() def tearDown(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) def test_mode_support(self): self.assertEqual(encryption_keys.rsa_modes_supported, [LOCAL_MODE, RESTRICTED_MODE]) diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py index 0ee10df..4f44424 100644 --- a/django_crypto_fields/tests/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -18,18 +18,12 @@ class TestFieldCryptor(TestCase): def setUp(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) encryption_keys.verbose = False encryption_keys.initialize() def tearDown(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) def test_can_verify_hash_as_none(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) diff --git a/django_crypto_fields/tests/tests/test_keys.py b/django_crypto_fields/tests/tests/test_keys.py index f29c213..230104c 100644 --- a/django_crypto_fields/tests/tests/test_keys.py +++ b/django_crypto_fields/tests/tests/test_keys.py @@ -21,32 +21,26 @@ class TestKeyCreator(TestCase): def setUp(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) encryption_keys.verbose = False encryption_keys.initialize() def tearDown(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) def test_keys_do_not_exist(self): encryption_keys.verbose = False - encryption_keys.reset(delete_all_keys="delete_all_keys") - for file in encryption_keys.files: + encryption_keys.reset_and_delete_keys() + for file in encryption_keys.filenames: self.assertFalse(Path(file).exists()) @override_settings(DJANGO_CRYPTO_FIELDS_KEY_PATH=mkdtemp()) def test_keys_exist(self): - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) + encryption_keys.reset_and_delete_keys(verbose=False) encryption_keys.verbose = False encryption_keys.initialize() - for file in encryption_keys.files: + for file in encryption_keys.filenames: self.assertTrue(Path(file).exists()) @override_settings(DEBUG=False, DJANGO_CRYPTO_FIELDS_KEY_PATH="/blah/blah/blah/blah") @@ -79,7 +73,7 @@ def test_invalid_production_path_raises(self): ) def test_create_keys_does_not_overwrite_production_keys(self): keys = Keys(verbose=False) - keys.reset(verbose=False) + keys.reset() self.assertRaises(DjangoCryptoFieldsKeyAlreadyExist, keys.create) @override_settings( diff --git a/django_crypto_fields/tests/tests/test_models.py b/django_crypto_fields/tests/tests/test_models.py index e62bf21..3a01d30 100644 --- a/django_crypto_fields/tests/tests/test_models.py +++ b/django_crypto_fields/tests/tests/test_models.py @@ -10,18 +10,12 @@ class TestModels(TestCase): def setUp(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) encryption_keys.verbose = False encryption_keys.initialize() def tearDown(self): - try: - encryption_keys.reset(delete_all_keys="delete_all_keys", verbose=False) - except FileNotFoundError: - pass + encryption_keys.reset_and_delete_keys(verbose=False) def test_encrypt_rsa(self): """Assert deconstruct.""" From fbf906c5be9c57e5cbd749de57335189871a14c2 Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 23:09:14 -0500 Subject: [PATCH 19/25] assume algorithm can only be aes or rsa, refactor --- django_crypto_fields/exceptions.py | 4 ++ django_crypto_fields/field_cryptor.py | 52 ++++++++++--------- .../fields/encrypted_decimal_field.py | 48 ++++++++++------- django_crypto_fields/key_path/key_path.py | 21 +++++--- django_crypto_fields/keys/keys.py | 42 ++++++++------- .../tests/crypto_keys/django_crypto_fields | 2 +- django_crypto_fields/tests/tests/test_keys.py | 2 +- django_crypto_fields/utils.py | 36 ++++++------- 8 files changed, 113 insertions(+), 94 deletions(-) diff --git a/django_crypto_fields/exceptions.py b/django_crypto_fields/exceptions.py index 787469b..6c78f04 100644 --- a/django_crypto_fields/exceptions.py +++ b/django_crypto_fields/exceptions.py @@ -52,3 +52,7 @@ class EncryptionLookupError(Exception): class MalformedCiphertextError(Exception): pass + + +class InvalidEncryptionAlgorithm(Exception): + pass diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index b5c54a4..e07b6e4 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -20,12 +20,18 @@ SALT, ) from .cryptor import Cryptor -from .exceptions import CipherError, EncryptionError, EncryptionKeyError +from .exceptions import ( + CipherError, + EncryptionError, + EncryptionKeyError, + InvalidEncryptionAlgorithm, +) from .keys import encryption_keys from .utils import ( get_crypt_model_cls, has_valid_value_or_raise, is_valid_ciphertext_or_raise, + safe_decode, safe_encode_utf8, ) @@ -49,6 +55,7 @@ class FieldCryptor: def __init__(self, algorithm: str, access_mode: str): self._using = None + self._algorithm = None self.algorithm = algorithm self.access_mode = access_mode self.aes_encryption_mode = AES_CIPHER.MODE_CBC @@ -61,6 +68,18 @@ def __init__(self, algorithm: str, access_mode: str): def __repr__(self) -> str: return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.access_mode}')" + @property + def algorithm(self): + return self._algorithm + + @algorithm.setter + def algorithm(self, value): + self._algorithm = value + if value not in [AES, RSA]: + raise InvalidEncryptionAlgorithm( + f"Invalid encryption algorithm. Expected 'aes' or 'rsa'. Got {value}" + ) + @property def salt_key(self): attr = "_".join([SALT, self.access_mode, PRIVATE]) @@ -123,13 +142,6 @@ def decrypt(self, hash_with_prefix: str): plaintext = self.cryptor.aes_decrypt(secret, self.access_mode) elif self.algorithm == RSA: plaintext = self.cryptor.rsa_decrypt(secret, self.access_mode) - else: - raise CipherError( - "Cannot determine algorithm for decryption." - " Valid options are {0}. Got {1}".format( - ", ".join(list(self.keys.key_filenames)), self.algorithm - ) - ) return plaintext @property @@ -171,10 +183,7 @@ def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: else: ciphertext = self.encrypt(value) value = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[0] - try: - value.decode() - except AttributeError: - pass + value = safe_decode(value) return value def get_ciphertext(self, value): @@ -183,19 +192,12 @@ def get_ciphertext(self, value): cipher = self.cryptor.aes_encrypt elif self.algorithm == RSA: cipher = self.cryptor.rsa_encrypt - try: - ciphertext = ( - HASH_PREFIX.encode(ENCODING) - + self.hash(value) - + CIPHER_PREFIX.encode(ENCODING) - + cipher(value, self.access_mode) - ) - except AttributeError as e: - raise CipherError( - "Cannot determine cipher method. Unknown " - "encryption algorithm. Valid options are {0}. " - "Got {1} ({2})".format(", ".join(self.keys.key_filenames), self.algorithm, e) - ) + ciphertext = ( + HASH_PREFIX.encode(ENCODING) + + self.hash(value) + + CIPHER_PREFIX.encode(ENCODING) + + cipher(value, self.access_mode) + ) return is_valid_ciphertext_or_raise(ciphertext, self.hash_size) def get_hash(self, ciphertext: bytes) -> bytes | None: diff --git a/django_crypto_fields/fields/encrypted_decimal_field.py b/django_crypto_fields/fields/encrypted_decimal_field.py index 383c5ba..2508003 100644 --- a/django_crypto_fields/fields/encrypted_decimal_field.py +++ b/django_crypto_fields/fields/encrypted_decimal_field.py @@ -7,6 +7,31 @@ class EncryptedDecimalField(BaseRsaField): description = "local-rsa encrypted field for 'IntegerField'" def __init__(self, *args, **kwargs): + self.validate_max_digits(kwargs) + self.validate_decimal_places(kwargs) + decimal_decimal_places = int(kwargs.get("decimal_places")) + decimal_max_digits = int(kwargs.get("max_digits")) + del kwargs["decimal_places"] + del kwargs["max_digits"] + super().__init__(*args, **kwargs) + self.decimal_decimal_places = decimal_decimal_places + self.decimal_max_digits = decimal_max_digits + + def to_string(self, value): + if isinstance(value, (str,)): + raise TypeError("Expected basestring. Got {0}".format(value)) + return str(value) + + def to_python(self, value): + """Returns as integer""" + retval = super(EncryptedDecimalField, self).to_python(value) + if retval: + if not self.field_cryptor.is_encrypted(retval): + retval = Decimal(retval).to_eng_string() + return retval + + @staticmethod + def validate_max_digits(kwargs): if "max_digits" not in kwargs: raise AttributeError( "EncryptedDecimalField requires attribute 'max_digits. " "Got none" @@ -19,6 +44,9 @@ def __init__(self, *args, **kwargs): f"EncryptedDecimalField attribute 'max_digits must be an " f'integer. Got {kwargs.get("max_digits")}' ) + + @staticmethod + def validate_decimal_places(kwargs): if "decimal_places" not in kwargs: raise AttributeError( "EncryptedDecimalField requires attribute 'decimal_places. " "Got none" @@ -31,23 +59,3 @@ def __init__(self, *args, **kwargs): f"EncryptedDecimalField attribute 'decimal_places must be an " f'integer. Got {kwargs.get("decimal_places")}' ) - decimal_decimal_places = int(kwargs.get("decimal_places")) - decimal_max_digits = int(kwargs.get("max_digits")) - del kwargs["decimal_places"] - del kwargs["max_digits"] - super().__init__(*args, **kwargs) - self.decimal_decimal_places = decimal_decimal_places - self.decimal_max_digits = decimal_max_digits - - def to_string(self, value): - if isinstance(value, (str,)): - raise TypeError("Expected basestring. Got {0}".format(value)) - return str(value) - - def to_python(self, value): - """Returns as integer""" - retval = super(EncryptedDecimalField, self).to_python(value) - if retval: - if not self.field_cryptor.is_encrypted(retval): - retval = Decimal(retval).to_eng_string() - return retval diff --git a/django_crypto_fields/key_path/key_path.py b/django_crypto_fields/key_path/key_path.py index 1a40f53..a41c209 100644 --- a/django_crypto_fields/key_path/key_path.py +++ b/django_crypto_fields/key_path/key_path.py @@ -30,14 +30,7 @@ class KeyPath: def __post_init__(self): path = get_keypath_from_settings() if not path: - if get_test_module_from_settings() in sys.argv: - path = mkdtemp() - else: - raise DjangoCryptoFieldsKeyPathError( - "Path may not be none. Production or debug systems must explicitly " - "set a valid path to the encryption keys. " - "See settings.DJANGO_CRYPTO_FIELDS_KEY_PATH." - ) + path = self.create_folder_for_tests_or_raise() elif not Path(path).exists(): raise DjangoCryptoFieldsKeyPathDoesNotExist( "Path to encryption keys does not exist. " @@ -59,3 +52,15 @@ def __post_init__(self): def __str__(self) -> str: return str(self.path) + + @staticmethod + def create_folder_for_tests_or_raise() -> PurePath: + if get_test_module_from_settings() in sys.argv: + path = PurePath(mkdtemp()) + else: + raise DjangoCryptoFieldsKeyPathError( + "Path may not be none. Production or debug systems must explicitly " + "set a valid path to the encryption keys. " + "See settings.DJANGO_CRYPTO_FIELDS_KEY_PATH." + ) + return path diff --git a/django_crypto_fields/keys/keys.py b/django_crypto_fields/keys/keys.py index 3d4120f..bc2974b 100644 --- a/django_crypto_fields/keys/keys.py +++ b/django_crypto_fields/keys/keys.py @@ -57,24 +57,7 @@ def initialize(self): self.keys = deepcopy(self.template) persist_key_path_or_raise() if not key_files_exist(self.path, self.key_prefix): - if auto_create_keys := get_auto_create_keys_from_settings(): - if not os.access(self.path, os.W_OK): - raise DjangoCryptoFieldsError( - "Cannot auto-create encryption keys. Folder is not writeable." - f"Got {self.path}" - ) - write_msg( - self.verbose, - style.SUCCESS(f" * settings.AUTO_CREATE_KEYS={auto_create_keys}.\n"), - ) - self.create() - else: - raise DjangoCryptoFieldsKeysDoNotExist( - f"Failed to find any encryption keys in path {self.path}. " - "If this is your first time loading " - "the project, set settings.AUTO_CREATE_KEYS=True and restart. " - "Make sure the folder is writeable." - ) + self.create_new_keys_or_raise() self.load_keys() self.rsa_modes_supported = sorted([k for k in self.keys[RSA]]) self.aes_modes_supported = sorted([k for k in self.keys[AES]]) @@ -98,7 +81,28 @@ def reset_and_delete_keys(self, verbose: bool | None = None): def get(self, k: str): return self.keys.get(k) - def create(self) -> None: + def create_new_keys_or_raise(self): + """Calls create after checking if allowed.""" + if auto_create_keys := get_auto_create_keys_from_settings(): + if not os.access(self.path, os.W_OK): + raise DjangoCryptoFieldsError( + "Cannot auto-create encryption keys. Folder is not writeable." + f"Got {self.path}" + ) + write_msg( + self.verbose, + style.SUCCESS(f" * settings.AUTO_CREATE_KEYS={auto_create_keys}.\n"), + ) + self._create() + else: + raise DjangoCryptoFieldsKeysDoNotExist( + f"Failed to find any encryption keys in path {self.path}. " + "If this is your first time loading " + "the project, set settings.AUTO_CREATE_KEYS=True and restart. " + "Make sure the folder is writeable." + ) + + def _create(self) -> None: """Generates RSA and AES keys as per `filenames`.""" if key_files_exist(self.path, self.key_prefix): raise DjangoCryptoFieldsKeyAlreadyExist( diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 5c39363..55d9b4f 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 03:29:04.747127+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 04:06:39.385684+00:00 diff --git a/django_crypto_fields/tests/tests/test_keys.py b/django_crypto_fields/tests/tests/test_keys.py index 230104c..67630c3 100644 --- a/django_crypto_fields/tests/tests/test_keys.py +++ b/django_crypto_fields/tests/tests/test_keys.py @@ -74,7 +74,7 @@ def test_invalid_production_path_raises(self): def test_create_keys_does_not_overwrite_production_keys(self): keys = Keys(verbose=False) keys.reset() - self.assertRaises(DjangoCryptoFieldsKeyAlreadyExist, keys.create) + self.assertRaises(DjangoCryptoFieldsKeyAlreadyExist, keys.create_new_keys_or_raise) @override_settings( DEBUG=False, diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 292a984..14a571f 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -90,6 +90,14 @@ def safe_encode_utf8(value) -> bytes: return value +def safe_decode(value) -> bytes: + try: + value.decode() + except AttributeError: + pass + return value + + def has_valid_hash_or_raise(ciphertext: bytes, hash_size: int) -> bool: """Verifies hash segment of ciphertext (bytes) and raises an exception if not OK. @@ -138,11 +146,11 @@ def has_valid_value_or_raise( raise MalformedCiphertextError("Expected a value, got just the encryption prefix.") has_valid_hash_or_raise(encoded_value, hash_size) if has_secret: - is_valid_ciphertext_or_raise(encoded_value, hash_size) + is_valid_ciphertext_or_raise(encoded_value) return value # note, is original passed value -def is_valid_ciphertext_or_raise(ciphertext: bytes, hash_size: int): +def is_valid_ciphertext_or_raise(ciphertext: bytes, hash_size: int | None = None): """Returns an unchanged ciphertext after verifying format cipher_prefix + hash + cipher_prefix + secret. """ @@ -158,22 +166,10 @@ def is_valid_ciphertext_or_raise(ciphertext: bytes, hash_size: int): raise MalformedCiphertextError( f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}" ) - try: - if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected hash prefix {HASH_PREFIX}" - ) - if ( - len( - ciphertext.split(HASH_PREFIX.encode(ENCODING))[1].split( - CIPHER_PREFIX.encode(ENCODING) - )[0] - ) - != hash_size - ): - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected hash size of {hash_size}." - ) - except IndexError: - MalformedCiphertextError("Malformed ciphertext.") + if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): + raise MalformedCiphertextError( + f"Malformed ciphertext. Expected hash prefix {HASH_PREFIX}" + ) + if hash_size is not None: + has_valid_hash_or_raise(ciphertext, hash_size) return ciphertext From 14ef68ebf1d790bb81938a534d0912bb3af3806b Mon Sep 17 00:00:00 2001 From: erikvw Date: Mon, 18 Mar 2024 23:35:03 -0500 Subject: [PATCH 20/25] fix non-relative imports --- django_crypto_fields/apps.py | 2 +- .../key_path/persist_key_path_or_raise.py | 17 ++++++++--------- django_crypto_fields/keys/keys.py | 12 ++++-------- django_crypto_fields/keys/utils.py | 10 +--------- .../tests/crypto_keys/django_crypto_fields | 2 +- django_crypto_fields/utils.py | 2 +- 6 files changed, 16 insertions(+), 29 deletions(-) diff --git a/django_crypto_fields/apps.py b/django_crypto_fields/apps.py index 80f687f..1f382d4 100644 --- a/django_crypto_fields/apps.py +++ b/django_crypto_fields/apps.py @@ -4,7 +4,7 @@ from django.apps import AppConfig as DjangoAppConfig from django.core.management.color import color_style -from django_crypto_fields.key_path import KeyPath +from .key_path import KeyPath class AppConfig(DjangoAppConfig): diff --git a/django_crypto_fields/key_path/persist_key_path_or_raise.py b/django_crypto_fields/key_path/persist_key_path_or_raise.py index 9c82efa..4178993 100644 --- a/django_crypto_fields/key_path/persist_key_path_or_raise.py +++ b/django_crypto_fields/key_path/persist_key_path_or_raise.py @@ -47,15 +47,14 @@ def read_last_used(folder: Path) -> tuple[PurePath | None, Path]: """Opens file `django_crypto_fields` and read last path.""" last_used_path = None filepath = Path(folder / "django_crypto_fields") - if filepath.exists(): - if "runtests.py" in sys.argv: - filepath.unlink() - else: - with filepath.open(mode="r") as f: - reader = csv.DictReader(f) - for row in reader: - last_used_path = PurePath(row.get("path")) - break + if "runtests.py" in sys.argv: + filepath.unlink(missing_ok=True) + elif filepath.exists(): + with filepath.open(mode="r") as f: + reader = csv.DictReader(f) + for row in reader: + last_used_path = PurePath(row.get("path")) + break if last_used_path and not Path(last_used_path).exists(): raise DjangoCryptoFieldsKeyPathError( style.ERROR( diff --git a/django_crypto_fields/keys/keys.py b/django_crypto_fields/keys/keys.py index bc2974b..d4a2cb1 100644 --- a/django_crypto_fields/keys/keys.py +++ b/django_crypto_fields/keys/keys.py @@ -10,20 +10,16 @@ from Cryptodome.Util import number from django.core.management.color import color_style -from django_crypto_fields.constants import AES, PRIVATE, PUBLIC, RSA, RSA_KEY_SIZE, SALT -from django_crypto_fields.exceptions import ( +from ..constants import AES, PRIVATE, PUBLIC, RSA, RSA_KEY_SIZE, SALT +from ..exceptions import ( DjangoCryptoFieldsError, DjangoCryptoFieldsKeyAlreadyExist, DjangoCryptoFieldsKeyError, DjangoCryptoFieldsKeysAlreadyLoaded, DjangoCryptoFieldsKeysDoNotExist, ) -from django_crypto_fields.key_path import KeyPath, persist_key_path_or_raise -from django_crypto_fields.utils import ( - get_auto_create_keys_from_settings, - get_key_prefix_from_settings, -) - +from ..key_path import KeyPath, persist_key_path_or_raise +from ..utils import get_auto_create_keys_from_settings, get_key_prefix_from_settings from .utils import get_filenames, get_template, key_files_exist, write_msg style = color_style() diff --git a/django_crypto_fields/keys/utils.py b/django_crypto_fields/keys/utils.py index 636ce6b..84b4c88 100644 --- a/django_crypto_fields/keys/utils.py +++ b/django_crypto_fields/keys/utils.py @@ -4,15 +4,7 @@ from pathlib import Path, PurePath from typing import Iterator -from django_crypto_fields.constants import ( - AES, - LOCAL_MODE, - PRIVATE, - PUBLIC, - RESTRICTED_MODE, - RSA, - SALT, -) +from ..constants import AES, LOCAL_MODE, PRIVATE, PUBLIC, RESTRICTED_MODE, RSA, SALT def get_template(path: PurePath, key_prefix: str) -> dict[str, dict[str, dict[str, PurePath]]]: diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 55d9b4f..c9db89d 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 04:06:39.385684+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 04:17:34.681606+00:00 diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 14a571f..7b3b66b 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -151,7 +151,7 @@ def has_valid_value_or_raise( def is_valid_ciphertext_or_raise(ciphertext: bytes, hash_size: int | None = None): - """Returns an unchanged ciphertext after verifying format cipher_prefix + + """Returns an unchanged ciphertext after verifying format hash_prefix + hash + cipher_prefix + secret. """ try: From 77609aea3ab4e48157b620819cd9db8b768076a7 Mon Sep 17 00:00:00 2001 From: erikvw Date: Wed, 20 Mar 2024 01:36:12 -0500 Subject: [PATCH 21/25] add classes to build and parse ciphers --- django_crypto_fields/cipher/__init__.py | 4 + django_crypto_fields/cipher/cipher.py | 44 ++++ django_crypto_fields/cipher/cipher_parser.py | 57 ++++++ django_crypto_fields/cryptor.py | 42 ++-- django_crypto_fields/field_cryptor.py | 191 +++++++----------- django_crypto_fields/fields/base_field.py | 43 ++-- .../tests/crypto_keys/django_crypto_fields | 2 +- .../tests/tests/test_cryptor.py | 58 +++--- .../tests/tests/test_field_cryptor.py | 70 +++---- django_crypto_fields/utils.py | 72 +------ 10 files changed, 282 insertions(+), 301 deletions(-) create mode 100644 django_crypto_fields/cipher/__init__.py create mode 100644 django_crypto_fields/cipher/cipher.py create mode 100644 django_crypto_fields/cipher/cipher_parser.py diff --git a/django_crypto_fields/cipher/__init__.py b/django_crypto_fields/cipher/__init__.py new file mode 100644 index 0000000..d9ae13d --- /dev/null +++ b/django_crypto_fields/cipher/__init__.py @@ -0,0 +1,4 @@ +from .cipher import Cipher +from .cipher_parser import CipherParser + +__all__ = ["Cipher", "CipherParser"] diff --git a/django_crypto_fields/cipher/cipher.py b/django_crypto_fields/cipher/cipher.py new file mode 100644 index 0000000..cd3b445 --- /dev/null +++ b/django_crypto_fields/cipher/cipher.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Callable + +from ..constants import CIPHER_PREFIX, HASH_PREFIX +from ..utils import make_hash, safe_encode_utf8 + +__all__ = ["Cipher"] + + +class Cipher: + """A class that given a value builds a cipher of the format + hash_prefix + hashed_value + cipher_prefix + secret. + + The secret is encrypted using the passed `encrypt` callable. + """ + + def __init__( + self, + value: str | bytes, + salt_key: bytes, + encrypt: Callable[[bytes], bytes] | None = None, + ): + encoded_value = safe_encode_utf8(value) + self.hash_prefix = b"" + self.hashed_value = b"" + self.cipher_prefix = b"" + self.secret = b"" + if salt_key: + self.hash_prefix: bytes = safe_encode_utf8(HASH_PREFIX) + self.hashed_value: bytes = make_hash(encoded_value, salt_key) + if encrypt: + self.secret = encrypt(encoded_value) + self.cipher_prefix: bytes = safe_encode_utf8(CIPHER_PREFIX) + + @property + def cipher(self) -> bytes: + return self.hash_prefix + self.hashed_value + self.cipher_prefix + self.secret + + def hash_with_prefix(self) -> bytes: + return self.hash_prefix + self.hashed_value + + def secret_with_prefix(self) -> bytes: + return self.cipher_prefix + self.secret diff --git a/django_crypto_fields/cipher/cipher_parser.py b/django_crypto_fields/cipher/cipher_parser.py new file mode 100644 index 0000000..ee04c61 --- /dev/null +++ b/django_crypto_fields/cipher/cipher_parser.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from ..constants import CIPHER_PREFIX, HASH_PREFIX +from ..exceptions import MalformedCiphertextError +from ..utils import make_hash, safe_encode_utf8 + +__all__ = ["CipherParser"] + + +class CipherParser: + def __init__(self, cipher: bytes, salt_key: bytes | None = None): + self._cipher_prefix = None + self._hash_prefix = None + self._hashed_value = None + self._secret = None + self.cipher = safe_encode_utf8(cipher) + self.salt_key = salt_key + self.validate_hashed_value() + self.validate_secret() + + @property + def hash_prefix(self) -> bytes | None: + if self.cipher: + hash_prefix = safe_encode_utf8(HASH_PREFIX) + self._hash_prefix = hash_prefix if self.cipher.startswith(hash_prefix) else None + return self._hash_prefix + + @property + def cipher_prefix(self) -> bytes | None: + if self.cipher: + cipher_prefix = safe_encode_utf8(CIPHER_PREFIX) + self._cipher_prefix = cipher_prefix if cipher_prefix in self.cipher else None + return self._cipher_prefix + + @property + def hashed_value(self) -> bytes | None: + if self.cipher and self.cipher.startswith(self.hash_prefix): + self._hashed_value = self.cipher.split(self.hash_prefix)[1].split( + self.cipher_prefix + )[0] + return self._hashed_value + + @property + def secret(self) -> bytes | None: + if self.cipher and safe_encode_utf8(CIPHER_PREFIX) in self.cipher: + self._secret = self.cipher.split(self.cipher_prefix)[1] + return self._secret + + def validate_hashed_value(self) -> None: + if self.hash_prefix and not self.hashed_value: + raise MalformedCiphertextError("Invalid hashed_value. Got None.") + elif self.salt_key and len(self.hashed_value) != len(make_hash("Foo", self.salt_key)): + raise MalformedCiphertextError("Invalid hashed_value. Incorrect size.") + + def validate_secret(self) -> None: + if self.cipher_prefix and not self.secret: + raise MalformedCiphertextError("Invalid secret. Got None.") diff --git a/django_crypto_fields/cryptor.py b/django_crypto_fields/cryptor.py index 2851365..bc701fb 100644 --- a/django_crypto_fields/cryptor.py +++ b/django_crypto_fields/cryptor.py @@ -6,7 +6,7 @@ from Cryptodome import Random from Cryptodome.Cipher import AES as AES_CIPHER -from .constants import AES, ENCODING, PRIVATE, PUBLIC, RSA +from .constants import AES, ENCODING, LOCAL_MODE, PRIVATE, PUBLIC, RSA from .exceptions import EncryptionError from .keys import encryption_keys from .utils import get_keypath_from_settings @@ -15,8 +15,6 @@ from Cryptodome.Cipher._mode_cbc import CbcMode from Cryptodome.Cipher.PKCS1_OAEP import PKCS1OAEP_Cipher - from .keys import Keys - class Cryptor: """Base class for all classes providing RSA and AES encryption @@ -26,9 +24,17 @@ class Cryptor: of this except the filenames are replaced with the actual keys. """ - def __init__(self): + def __init__(self, algorithm: AES | RSA, access_mode: PRIVATE | LOCAL_MODE = None) -> None: + self.algorithm = algorithm self.aes_encryption_mode: int = AES_CIPHER.MODE_CBC - self.keys: Keys = encryption_keys + aes_key_attr: str = "_".join([AES, access_mode, PRIVATE, "key"]) + self.aes_key: bytes = getattr(encryption_keys, aes_key_attr) + rsa_key_attr = "_".join([RSA, access_mode, PUBLIC, "key"]) + self.rsa_public_key: PKCS1OAEP_Cipher = getattr(encryption_keys, rsa_key_attr) + rsa_key_attr = "_".join([RSA, access_mode, PRIVATE, "key"]) + self.rsa_private_key: PKCS1OAEP_Cipher = getattr(encryption_keys, rsa_key_attr) + self.encrypt = getattr(self, f"_{self.algorithm.lower()}_encrypt") + self.decrypt = getattr(self, f"_{self.algorithm.lower()}_decrypt") def get_with_padding(self, plaintext: str | bytes, block_size: int) -> bytes: """Return string padded so length is a multiple of the block size. @@ -73,42 +79,34 @@ def get_without_padding(self, plaintext: str | bytes) -> bytes: return plaintext[:-1] return plaintext[:-padding_length] - def aes_encrypt(self, plaintext: str | bytes, mode: str) -> bytes: - aes_key_attr: str = "_".join([AES, mode, PRIVATE, "key"]) - aes_key: bytes = getattr(self.keys, aes_key_attr) + def _aes_encrypt(self, plaintext: str | bytes) -> bytes: iv: bytes = Random.new().read(AES_CIPHER.block_size) - cipher: CbcMode = AES_CIPHER.new(aes_key, self.aes_encryption_mode, iv) + cipher: CbcMode = AES_CIPHER.new(self.aes_key, self.aes_encryption_mode, iv) padded_plaintext = self.get_with_padding(plaintext, cipher.block_size) return iv + cipher.encrypt(padded_plaintext) - def aes_decrypt(self, ciphertext: bytes, mode: str) -> str: - aes_key_attr: str = "_".join([AES, mode, PRIVATE, "key"]) - aes_key: bytes = getattr(self.keys, aes_key_attr) + def _aes_decrypt(self, ciphertext: bytes) -> str: iv = ciphertext[: AES_CIPHER.block_size] - cipher: CbcMode = AES_CIPHER.new(aes_key, self.aes_encryption_mode, iv) + cipher: CbcMode = AES_CIPHER.new(self.aes_key, self.aes_encryption_mode, iv) plaintext = cipher.decrypt(ciphertext)[AES_CIPHER.block_size :] return self.get_without_padding(plaintext).decode() - def rsa_encrypt(self, plaintext: str | bytes, mode: int) -> bytes: - rsa_key_attr = "_".join([RSA, mode, PUBLIC, "key"]) - rsa_key: PKCS1OAEP_Cipher = getattr(self.keys, rsa_key_attr) + def _rsa_encrypt(self, plaintext: str | bytes) -> bytes: try: plaintext = plaintext.encode(ENCODING) except AttributeError: pass try: - ciphertext = rsa_key.encrypt(plaintext) + ciphertext = self.rsa_public_key.encrypt(plaintext) except (ValueError, TypeError) as e: raise EncryptionError(f"RSA encryption failed for value. Got '{e}'") return ciphertext - def rsa_decrypt(self, ciphertext: bytes, mode: str) -> str: - rsa_key_attr = "_".join([RSA, mode, PRIVATE, "key"]) - rsa_key: PKCS1OAEP_Cipher = getattr(self.keys, rsa_key_attr) + def _rsa_decrypt(self, ciphertext: bytes) -> str: try: - plaintext = rsa_key.decrypt(ciphertext) + plaintext = self.rsa_private_key.decrypt(ciphertext) except ValueError as e: raise EncryptionError( - f"{e} Using {rsa_key_attr} from key_path=`{get_keypath_from_settings()}`." + f"{e} Using RSA from key_path=`{get_keypath_from_settings()}`." ) return plaintext.decode(ENCODING) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index e07b6e4..044d5c7 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -1,39 +1,17 @@ from __future__ import annotations -import binascii -import hashlib from typing import TYPE_CHECKING, Type from Cryptodome.Cipher import AES as AES_CIPHER from django.apps import apps as django_apps from django.core.exceptions import ObjectDoesNotExist -from .constants import ( - AES, - CIPHER_PREFIX, - ENCODING, - HASH_ALGORITHM, - HASH_PREFIX, - HASH_ROUNDS, - PRIVATE, - RSA, - SALT, -) +from .cipher import Cipher, CipherParser +from .constants import AES, CIPHER_PREFIX, ENCODING, HASH_PREFIX, PRIVATE, RSA, SALT from .cryptor import Cryptor -from .exceptions import ( - CipherError, - EncryptionError, - EncryptionKeyError, - InvalidEncryptionAlgorithm, -) +from .exceptions import EncryptionError, EncryptionKeyError, InvalidEncryptionAlgorithm from .keys import encryption_keys -from .utils import ( - get_crypt_model_cls, - has_valid_value_or_raise, - is_valid_ciphertext_or_raise, - safe_decode, - safe_encode_utf8, -) +from .utils import get_crypt_model_cls, make_hash, safe_decode, safe_encode_utf8 if TYPE_CHECKING: from .models import Crypt @@ -52,6 +30,8 @@ class FieldCryptor: """ crypt_model = "django_crypto_fields.crypt" + cryptor_cls = Cryptor + cipher_cls = Cipher def __init__(self, algorithm: str, access_mode: str): self._using = None @@ -62,7 +42,7 @@ def __init__(self, algorithm: str, access_mode: str): self.cipher_buffer_key = f"{self.algorithm}_{self.access_mode}" self.cipher_buffer = {self.cipher_buffer_key: {}} self.keys = encryption_keys - self.cryptor = Cryptor() + self.cryptor = self.cryptor_cls(algorithm=algorithm, access_mode=access_mode) self.hash_size: int = len(self.hash("Foo")) def __repr__(self) -> str: @@ -80,6 +60,9 @@ def algorithm(self, value): f"Invalid encryption algorithm. Expected 'aes' or 'rsa'. Got {value}" ) + def hash(self, value): + return make_hash(value, self.salt_key) + @property def salt_key(self): attr = "_".join([SALT, self.access_mode, PRIVATE]) @@ -96,21 +79,11 @@ def crypt_model_cls(self) -> Type[Crypt]: """ return get_crypt_model_cls() - def hash(self, plaintext): - """Returns a hexified hash of a plaintext value (as bytes). - - The hashed value is used as a signature of the "secret". - """ - plaintext = safe_encode_utf8(plaintext) - dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, plaintext, self.salt_key, HASH_ROUNDS) - return binascii.hexlify(dk) - def encrypt(self, value: str | bytes | None, update: bool | None = None): - """Returns ciphertext as byte data using either an - RSA or AES cipher. + """Returns either an RSA or AES cipher. * 'value' is either plaintext or ciphertext - * 'ciphertext' is a byte value of hash_prefix + * 'cipher' is a byte value of hash_prefix + hashed_value + cipher_prefix + secret. For example: enc1:::234234ed234a24enc2::\x0e\xb9\xae\x13s\x8d @@ -118,31 +91,28 @@ def encrypt(self, value: str | bytes | None, update: bool | None = None): * 'value' is not re-encrypted if already encrypted and properly formatted 'ciphertext'. """ - ciphertext = None + cipher = None update = True if update is None else update - value = safe_encode_utf8(value) - if value is not None and value != b"" and not self.is_encrypted(value): - ciphertext = self.get_ciphertext(value) + encoded_value = safe_encode_utf8(value) + if encoded_value and not self.is_encrypted(encoded_value): + cipher = self.cipher_cls(value, self.salt_key, encrypt=self.cryptor.encrypt) if update: - self.update_crypt(ciphertext) - return ciphertext + self.update_crypt(cipher) + return getattr(cipher, "cipher", encoded_value) - def decrypt(self, hash_with_prefix: str): + def decrypt(self, hash_with_prefix: str | bytes): """Returns decrypted secret or None. - Secret is retrieved from `Crypt` using the hash. + Secret is retrieved from `Crypt` using the hash_with_prefix + coming from the field of the user model. - hash_with_prefix = hash_prefix+hash. + hash_with_prefix = hash_prefix+hash_value. """ - plaintext = None hash_with_prefix = safe_encode_utf8(hash_with_prefix) - if self.is_encrypted(hash_with_prefix): + if hash_with_prefix and self.is_encrypted(hash_with_prefix): if secret := self.fetch_secret(hash_with_prefix): - if self.algorithm == AES: - plaintext = self.cryptor.aes_decrypt(secret, self.access_mode) - elif self.algorithm == RSA: - plaintext = self.cryptor.rsa_decrypt(secret, self.access_mode) - return plaintext + return self.cryptor.decrypt(secret) + return None @property def using(self): @@ -151,102 +121,83 @@ def using(self): self._using = app_config.crypt_model_using return self._using - def update_crypt(self, ciphertext): - """Updates cipher model (Crypt) and temporary buffer.""" - if is_valid_ciphertext_or_raise(ciphertext, self.hash_size): - hashed_value = self.get_hash(ciphertext) - secret = self.get_secret(ciphertext) - self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) - try: - crypt = self.crypt_model_cls.objects.using(self.using).get( - hash=hashed_value, algorithm=self.algorithm, mode=self.access_mode - ) - crypt.secret = secret - crypt.save() - except ObjectDoesNotExist: - self.crypt_model_cls.objects.using(self.using).create( - hash=hashed_value, - secret=secret, - algorithm=self.algorithm, - cipher_mode=self.aes_encryption_mode, - mode=self.access_mode, - ) + def update_crypt(self, cipher: Cipher): + """Updates Crypt model and cipher_buffer.""" + self.cipher_buffer[self.cipher_buffer_key].update({cipher.hashed_value: cipher.secret}) + try: + crypt = self.crypt_model_cls.objects.using(self.using).get( + hash=cipher.hashed_value, algorithm=self.algorithm, mode=self.access_mode + ) + crypt.secret = cipher.secret + crypt.save() + except ObjectDoesNotExist: + self.crypt_model_cls.objects.using(self.using).create( + hash=cipher.hashed_value, + secret=cipher.secret, + algorithm=self.algorithm, + cipher_mode=self.aes_encryption_mode, + mode=self.access_mode, + ) def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: - """Returns the prefix + hash as stored in the DB table column of + """Returns the prefix + hash_value as stored in the DB table column of your model's "encrypted" field. Used by get_prep_value() """ + hash_with_prefix = None if value is None or value in ["", b""]: pass # return None or empty string/byte else: - ciphertext = self.encrypt(value) - value = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[0] - value = safe_decode(value) - return value - - def get_ciphertext(self, value): - cipher = None - if self.algorithm == AES: - cipher = self.cryptor.aes_encrypt - elif self.algorithm == RSA: - cipher = self.cryptor.rsa_encrypt - ciphertext = ( - HASH_PREFIX.encode(ENCODING) - + self.hash(value) - + CIPHER_PREFIX.encode(ENCODING) - + cipher(value, self.access_mode) - ) - return is_valid_ciphertext_or_raise(ciphertext, self.hash_size) - - def get_hash(self, ciphertext: bytes) -> bytes | None: - """Returns the hashed_value given a ciphertext or None.""" - ciphertext = safe_encode_utf8(ciphertext) - return ciphertext[len(HASH_PREFIX) :][: self.hash_size] or None - - def get_secret(self, ciphertext: bytes) -> bytes | None: - """Returns the secret given a ciphertext.""" - if ciphertext is None: - secret = None - elif self.is_encrypted(ciphertext): - secret = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - else: - raise CipherError("Expected a ciphertext or None") - return secret + cipher = self.encrypt(value) + hash_with_prefix = cipher.split(CIPHER_PREFIX.encode(ENCODING))[0] + hash_with_prefix = safe_decode(hash_with_prefix) + return hash_with_prefix or value def fetch_secret(self, hash_with_prefix: bytes): - hashed_value = self.get_hash(hash_with_prefix) + """Fetch the secret from the DB or the buffer using + the hashed_value as the lookup. + + If not found in buffer, lookup in DB and update the buffer. + + A secret is the segment to follow the `enc2:::`. + """ + hash_with_prefix = safe_encode_utf8(hash_with_prefix) + hashed_value = hash_with_prefix[len(HASH_PREFIX) :][: self.hash_size] or None secret = self.cipher_buffer[self.cipher_buffer_key].get(hashed_value) if not secret: try: - cipher = ( + data = ( self.crypt_model_cls.objects.using(self.using) .values("secret") .get(hash=hashed_value, algorithm=self.algorithm, mode=self.access_mode) ) - secret = cipher.get("secret") + secret = data.get("secret") self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) except ObjectDoesNotExist: raise EncryptionError( - f"Failed to get secret for given {self.algorithm} " - f"{self.access_mode} hash. Got '{hash_with_prefix}'" + f"EncryptionError. Failed to get secret for given {self.algorithm} " + f"{self.access_mode} hash. Got '{str(hash_with_prefix)}'" ) return secret def is_encrypted(self, value: str | bytes | None) -> bool: """Returns True if value is encrypted. - Value can be: - * a string value + + An encrypted value starts with the hash_prefix. + + Inspects a value that is: + * a string value -> False * a well-formed hash - * a well-formed hash+secret. + * a well-formed hash_prefix + hash -> True + * a well-formed hash + secret. """ is_encrypted = False if value is not None: value = safe_encode_utf8(value) - if value[: len(HASH_PREFIX)] == HASH_PREFIX.encode(ENCODING): - has_secret = value[: len(CIPHER_PREFIX)] == CIPHER_PREFIX.encode(ENCODING) - has_valid_value_or_raise(value, self.hash_size, has_secret=has_secret) + if value.startswith(safe_encode_utf8(HASH_PREFIX)): + p = CipherParser(value, self.salt_key) + p.validate_hashed_value() is_encrypted = True return is_encrypted diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index 6baa9d6..d5392f7 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from typing import TYPE_CHECKING from django.conf import settings @@ -10,11 +9,9 @@ from ..constants import ENCODING, HASH_PREFIX, LOCAL_MODE, RSA from ..exceptions import ( - CipherError, DjangoCryptoFieldsKeysNotLoaded, EncryptionError, EncryptionLookupError, - MalformedCiphertextError, ) from ..field_cryptor import FieldCryptor from ..keys import encryption_keys @@ -78,24 +75,10 @@ def formfield(self, **kwargs): return super(BaseField, self).formfield(**defaults) def decrypt(self, value): - decrypted_value = None if value is None or value in ["", b""]: - return value - try: + decrypted_value = value + else: decrypted_value = self.field_cryptor.decrypt(value) - if not decrypted_value: - self.readonly = True # did not decrypt - decrypted_value = value - except CipherError as e: - sys.stdout.write(style.ERROR(f"CipherError. Got {e}\n")) - sys.stdout.flush() - except EncryptionError as e: - sys.stdout.write(style.ERROR(f"EncryptionError. Got {e}\n")) - sys.stdout.flush() - raise - except MalformedCiphertextError as e: - sys.stdout.write(style.ERROR(f"MalformedCiphertextError. Got {e}\n")) - sys.stdout.flush() return decrypted_value def from_db_value(self, value, *args): @@ -119,24 +102,28 @@ def get_prep_lookup(self, lookup_type, value): Since the available value is the hash, only exact match lookup types are supported. """ - supported_lookups = ["iexact", "exact", "in", "isnull"] - if value is None or value in ["", b""] or lookup_type not in supported_lookups: + # TODO: why value in ["", b""] and not just value == b"" + if value is None or value in ["", b""]: pass else: - supported_lookups = ["iexact", "exact", "in", "isnull"] - if lookup_type not in supported_lookups: - raise EncryptionLookupError( - f"Field type only supports supports '{supported_lookups}' " - f"lookups. Got '{lookup_type}'" - ) + self.raise_if_unsupported_lookup(lookup_type) if lookup_type == "isnull": value = self.get_isnull_as_lookup(value) elif lookup_type == "in": - self.get_in_as_lookup(value) + value = self.get_in_as_lookup(value) else: value = HASH_PREFIX.encode(ENCODING) + self.field_cryptor.hash(value) return super().get_prep_lookup(lookup_type, value) + @staticmethod + def raise_if_unsupported_lookup(lookup_type): + supported_lookups = ["iexact", "exact", "in", "isnull"] + if lookup_type not in supported_lookups: + raise EncryptionLookupError( + f"Field type only supports supports '{supported_lookups}' " + f"lookups. Got '{lookup_type}'" + ) + def get_isnull_as_lookup(self, value): return value diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index c9db89d..7eb33c3 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-19 04:17:34.681606+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-20 06:26:42.256762+00:00 diff --git a/django_crypto_fields/tests/tests/test_cryptor.py b/django_crypto_fields/tests/tests/test_cryptor.py index d911797..80cfce1 100644 --- a/django_crypto_fields/tests/tests/test_cryptor.py +++ b/django_crypto_fields/tests/tests/test_cryptor.py @@ -23,73 +23,73 @@ def test_mode_support(self): def test_encrypt_rsa(self): """Assert successful RSA roundtrip.""" - cryptor = Cryptor() - plaintext = "erik is a pleeb!!" for mode in encryption_keys.rsa_modes_supported: - ciphertext = cryptor.rsa_encrypt(plaintext, mode) - self.assertEqual(plaintext, cryptor.rsa_decrypt(ciphertext, mode)) + cryptor = Cryptor(algorithm=RSA, access_mode=mode) + plaintext = "erik is a pleeb!!" + ciphertext = cryptor.encrypt(plaintext) + self.assertEqual(plaintext, cryptor.decrypt(ciphertext)) def test_encrypt_aes(self): """Assert successful AES roundtrip.""" - cryptor = Cryptor() - plaintext = "erik is a pleeb!!" for mode in encryption_keys.aes_modes_supported: - ciphertext = cryptor.aes_encrypt(plaintext, mode) - self.assertEqual(plaintext, cryptor.aes_decrypt(ciphertext, mode)) + cryptor = Cryptor(algorithm=AES, access_mode=mode) + plaintext = "erik is a pleeb!!" + ciphertext = cryptor.encrypt(plaintext) + self.assertEqual(plaintext, cryptor.decrypt(ciphertext)) def test_encrypt_rsa_length(self): """Assert RSA raises EncryptionError if plaintext is too long.""" - cryptor = Cryptor() for mode in encryption_keys.rsa_modes_supported: + cryptor = Cryptor(algorithm=RSA, access_mode=mode) max_length = encryption_keys.rsa_key_info[mode]["max_message_length"] plaintext = "".join(["a" for _ in range(0, max_length)]) - cryptor.rsa_encrypt(plaintext, mode) - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext + "a", mode) + cryptor.encrypt(plaintext) + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext + "a") def test_rsa_encoding(self): """Assert successful RSA roundtrip of byte return str.""" - cryptor = Cryptor() + cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç".encode("utf-8") - ciphertext = cryptor.rsa_encrypt(plaintext, LOCAL_MODE) - t2 = type(cryptor.rsa_decrypt(ciphertext, LOCAL_MODE)) + ciphertext = cryptor.encrypt(plaintext) + t2 = type(cryptor.decrypt(ciphertext)) self.assertTrue(type(t2), "str") def test_rsa_type(self): """Assert fails for anything but str and byte.""" - cryptor = Cryptor() + cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) plaintext = 1 - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext, LOCAL_MODE) + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext) plaintext = 1.0 - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext, LOCAL_MODE) + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext) plaintext = datetime.today() - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext, LOCAL_MODE) + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext) def test_no_re_encrypt(self): """Assert raise error if attempting to encrypt a cipher.""" - cryptor = Cryptor() + cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) plaintext = "erik is a pleeb!!" - ciphertext1 = cryptor.rsa_encrypt(plaintext, LOCAL_MODE) - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, ciphertext1, LOCAL_MODE) + ciphertext1 = cryptor.encrypt(plaintext) + self.assertRaises(EncryptionError, cryptor.encrypt, ciphertext1) def test_rsa_roundtrip(self): - cryptor = Cryptor() plaintext = ( "erik is a pleeb! ERIK IS A PLEEB 0123456789!@#$%^&*()" "_-+={[}]|\"':;>.<,?/~`±§" ) - for mode in cryptor.keys.get(RSA): + for mode in encryption_keys.rsa_modes_supported: + cryptor = Cryptor(algorithm=RSA, access_mode=mode) try: - ciphertext = cryptor.rsa_encrypt(plaintext, mode) + ciphertext = cryptor.encrypt(plaintext) except (AttributeError, TypeError) as e: self.fail(f"Failed encrypt: {mode} public ({e})\n") - self.assertTrue(plaintext == cryptor.rsa_decrypt(ciphertext, mode)) + self.assertTrue(plaintext == cryptor.decrypt(ciphertext)) def test_aes_roundtrip(self): - cryptor = Cryptor() plaintext = ( "erik is a pleeb!\nERIK IS A PLEEB\n0123456789!@#$%^&*()_" "-+={[}]|\"':;>.<,?/~`±§\n" ) - for mode in cryptor.keys.get(AES): - ciphertext = cryptor.aes_encrypt(plaintext, mode) + for mode in encryption_keys.aes_modes_supported: + cryptor = Cryptor(algorithm=AES, access_mode=mode) + ciphertext = cryptor.encrypt(plaintext) self.assertTrue(plaintext != ciphertext) - self.assertTrue(plaintext == cryptor.aes_decrypt(ciphertext, mode)) + self.assertTrue(plaintext == cryptor.decrypt(ciphertext)) diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py index 4f44424..a45d801 100644 --- a/django_crypto_fields/tests/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -1,18 +1,15 @@ from django.db import transaction from django.db.utils import IntegrityError -from django.test import TestCase +from django.test import TestCase, tag +from django_crypto_fields.cipher import CipherParser from django_crypto_fields.constants import AES, ENCODING, HASH_PREFIX, LOCAL_MODE, RSA from django_crypto_fields.cryptor import Cryptor from django_crypto_fields.exceptions import MalformedCiphertextError from django_crypto_fields.field_cryptor import FieldCryptor from django_crypto_fields.keys import encryption_keys +from django_crypto_fields.utils import has_valid_hash_or_raise -from ...utils import ( - has_valid_hash_or_raise, - has_valid_value_or_raise, - is_valid_ciphertext_or_raise, -) from ..models import TestModel @@ -67,23 +64,16 @@ def test_can_verify_hash_raises(self): MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size ) - def test_verify_with_secret(self): + def test_verify_hashed_value(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") - self.assertTrue(is_valid_ciphertext_or_raise(value, field_cryptor.hash_size)) - - def test_raises_on_verify_without_secret(self): - field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( - "Mohammed Ali floats like a butterfly" - ) - self.assertRaises( - MalformedCiphertextError, - is_valid_ciphertext_or_raise, - value, - field_cryptor.hash_size, - ) + p = CipherParser(value, field_cryptor.salt_key) + try: + p.validate_hashed_value() + except MalformedCiphertextError: + self.fail("MalformedCiphertextError unexpectedly raised") + @tag("6") def test_verify_is_encrypted(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( @@ -100,12 +90,9 @@ def test_verify_is_not_encrypted(self): def test_verify_value(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - value = "Mohammed Ali floats like a butterfly" - self.assertRaises( - MalformedCiphertextError, has_valid_value_or_raise, value, field_cryptor.hash_size - ) - value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") - self.assertEqual(value, has_valid_value_or_raise(value, field_cryptor.hash_size)) + cipher = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") + p = CipherParser(cipher) + self.assertIsNotNone(p.secret) def test_rsa_field_encryption(self): """Assert successful RSA field roundtrip.""" @@ -166,41 +153,44 @@ def test_rsa_update_crypt_model(self): retrieved by hash, and decrypted. """ plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - cryptor = Cryptor() + cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) field_cryptor = FieldCryptor(RSA, LOCAL_MODE) hashed_value = field_cryptor.hash(plaintext) - ciphertext1 = field_cryptor.encrypt(plaintext, update=False) - field_cryptor.update_crypt(ciphertext1) + field_cryptor.encrypt(plaintext, update=True) secret = field_cryptor.crypt_model_cls.objects.get(hash=hashed_value).secret field_cryptor.fetch_secret(HASH_PREFIX.encode(ENCODING) + hashed_value) - self.assertEqual(plaintext, cryptor.rsa_decrypt(secret, LOCAL_MODE)) + self.assertEqual(plaintext, cryptor.decrypt(secret)) def test_aes_update_crypt_model(self): """Asserts plaintext can be encrypted, saved to model, retrieved by hash, and decrypted. """ plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - cryptor = Cryptor() field_cryptor = FieldCryptor(AES, LOCAL_MODE) + field_cryptor.encrypt(plaintext, update=True) hashed_value = field_cryptor.hash(plaintext) - ciphertext1 = field_cryptor.encrypt(plaintext, update=False) - field_cryptor.update_crypt(ciphertext1) secret = field_cryptor.crypt_model_cls.objects.get(hash=hashed_value).secret field_cryptor.fetch_secret(HASH_PREFIX.encode(ENCODING) + hashed_value) - self.assertEqual(plaintext, cryptor.aes_decrypt(secret, LOCAL_MODE)) + self.assertEqual(plaintext, field_cryptor.cryptor.decrypt(secret)) + @tag("3") def test_get_secret(self): """Asserts secret is returned either as None or the secret.""" - cryptor = Cryptor() field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + plaintext = None - ciphertext = field_cryptor.encrypt(plaintext) - secret = field_cryptor.get_secret(ciphertext) + cipher = field_cryptor.encrypt(plaintext, update=True) + secret = CipherParser(cipher).secret self.assertIsNone(secret) + self.assertEqual(plaintext, field_cryptor.decrypt(secret)) + plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - ciphertext = field_cryptor.encrypt(plaintext) - secret = field_cryptor.get_secret(ciphertext) - self.assertEqual(plaintext, cryptor.rsa_decrypt(secret, LOCAL_MODE)) + cipher = field_cryptor.encrypt(plaintext, update=True) + cipher = CipherParser(cipher) + self.assertIsNotNone(cipher.secret) + self.assertEqual( + plaintext, field_cryptor.decrypt(cipher.hash_prefix + cipher.hashed_value) + ) def test_rsa_field_as_none(self): """Asserts RSA roundtrip on None.""" diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 7b3b66b..919384d 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -1,5 +1,7 @@ from __future__ import annotations +import binascii +import hashlib import sys from typing import TYPE_CHECKING, Type @@ -7,7 +9,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from .constants import CIPHER_PREFIX, ENCODING, HASH_PREFIX +from .constants import CIPHER_PREFIX, ENCODING, HASH_ALGORITHM, HASH_PREFIX, HASH_ROUNDS from .exceptions import MalformedCiphertextError if TYPE_CHECKING: @@ -103,15 +105,9 @@ def has_valid_hash_or_raise(ciphertext: bytes, hash_size: int) -> bool: raises an exception if not OK. """ ciphertext = safe_encode_utf8(ciphertext) - hash_prefix = HASH_PREFIX.encode(ENCODING) - if ciphertext == HASH_PREFIX.encode(ENCODING): - raise MalformedCiphertextError(f"Ciphertext has not hash. Got {ciphertext}") - if not ciphertext[: len(hash_prefix)] == hash_prefix: - raise MalformedCiphertextError( - f"Ciphertext must start with {hash_prefix}. " - f"Got {ciphertext[:len(hash_prefix)]}" - ) - hash_value = ciphertext[len(hash_prefix) :].split(CIPHER_PREFIX.encode(ENCODING))[0] + hash_value = ciphertext[len(safe_encode_utf8(HASH_PREFIX)) :].split( + safe_encode_utf8(CIPHER_PREFIX) + )[0] if len(hash_value) != hash_size: raise MalformedCiphertextError( "Expected hash prefix to be followed by a hash. Got something else or nothing" @@ -119,57 +115,11 @@ def has_valid_hash_or_raise(ciphertext: bytes, hash_size: int) -> bool: return True -def has_valid_value_or_raise( - value: str | bytes, hash_size: int, has_secret=None -) -> str | bytes: - """Encodes the value, validates its format, and returns it - or raises an exception. - - A value is either a value that can be encrypted or one that - already is encrypted. +def make_hash(value, salt_key) -> bytes: + """Returns a hexified hash of a plaintext value (as bytes). - * A value cannot just be equal to HASH_PREFIX or CIPHER_PREFIX; - * A value prefixed with HASH_PREFIX must be followed by a - valid hash (by length); - * A value prefixed with HASH_PREFIX + hashed_value + - CIPHER_PREFIX must be followed by some text; - * A value prefix by CIPHER_PREFIX must be followed by - some text; + The hashed value is used as a signature of the "secret". """ - has_secret = True if has_secret is None else has_secret encoded_value = safe_encode_utf8(value) - if encoded_value is not None and encoded_value != b"": - if encoded_value in [ - HASH_PREFIX.encode(ENCODING), - CIPHER_PREFIX.encode(ENCODING), - ]: - raise MalformedCiphertextError("Expected a value, got just the encryption prefix.") - has_valid_hash_or_raise(encoded_value, hash_size) - if has_secret: - is_valid_ciphertext_or_raise(encoded_value) - return value # note, is original passed value - - -def is_valid_ciphertext_or_raise(ciphertext: bytes, hash_size: int | None = None): - """Returns an unchanged ciphertext after verifying format hash_prefix + - hash + cipher_prefix + secret. - """ - try: - ciphertext.split(HASH_PREFIX.encode(ENCODING))[1] - except IndexError: - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected prefixes {HASH_PREFIX}" - ) - try: - ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - except IndexError: - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected prefixes {CIPHER_PREFIX}" - ) - if ciphertext[: len(HASH_PREFIX)] != HASH_PREFIX.encode(ENCODING): - raise MalformedCiphertextError( - f"Malformed ciphertext. Expected hash prefix {HASH_PREFIX}" - ) - if hash_size is not None: - has_valid_hash_or_raise(ciphertext, hash_size) - return ciphertext + dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, encoded_value, salt_key, HASH_ROUNDS) + return binascii.hexlify(dk) From 530c76ac198dba91b5bdb84583d0055bd598659b Mon Sep 17 00:00:00 2001 From: erikvw Date: Wed, 20 Mar 2024 10:09:43 -0500 Subject: [PATCH 22/25] store runtime secrets in cache --- django_crypto_fields/field_cryptor.py | 44 +++++++++++++-------------- django_crypto_fields/utils.py | 15 +-------- 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index 044d5c7..e4c034f 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -4,9 +4,10 @@ from Cryptodome.Cipher import AES as AES_CIPHER from django.apps import apps as django_apps +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist -from .cipher import Cipher, CipherParser +from .cipher import Cipher from .constants import AES, CIPHER_PREFIX, ENCODING, HASH_PREFIX, PRIVATE, RSA, SALT from .cryptor import Cryptor from .exceptions import EncryptionError, EncryptionKeyError, InvalidEncryptionAlgorithm @@ -39,7 +40,7 @@ def __init__(self, algorithm: str, access_mode: str): self.algorithm = algorithm self.access_mode = access_mode self.aes_encryption_mode = AES_CIPHER.MODE_CBC - self.cipher_buffer_key = f"{self.algorithm}_{self.access_mode}" + self.cipher_buffer_key = b"{self.algorithm}_{self.access_mode}" self.cipher_buffer = {self.cipher_buffer_key: {}} self.keys = encryption_keys self.cryptor = self.cryptor_cls(algorithm=algorithm, access_mode=access_mode) @@ -123,13 +124,10 @@ def using(self): def update_crypt(self, cipher: Cipher): """Updates Crypt model and cipher_buffer.""" - self.cipher_buffer[self.cipher_buffer_key].update({cipher.hashed_value: cipher.secret}) try: crypt = self.crypt_model_cls.objects.using(self.using).get( hash=cipher.hashed_value, algorithm=self.algorithm, mode=self.access_mode ) - crypt.secret = cipher.secret - crypt.save() except ObjectDoesNotExist: self.crypt_model_cls.objects.using(self.using).create( hash=cipher.hashed_value, @@ -138,6 +136,12 @@ def update_crypt(self, cipher: Cipher): cipher_mode=self.aes_encryption_mode, mode=self.access_mode, ) + else: + crypt.secret = cipher.secret + crypt.save() + cache.set(self.cipher_buffer_key + cipher.hashed_value, cipher.secret) + # self.cipher_buffer[self.cipher_buffer_key].update( + # {cipher.hashed_value: cipher.secret}) def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: """Returns the prefix + hash_value as stored in the DB table column of @@ -154,7 +158,7 @@ def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: hash_with_prefix = safe_decode(hash_with_prefix) return hash_with_prefix or value - def fetch_secret(self, hash_with_prefix: bytes): + def fetch_secret(self, hash_with_prefix: bytes) -> bytes | None: """Fetch the secret from the DB or the buffer using the hashed_value as the lookup. @@ -162,9 +166,11 @@ def fetch_secret(self, hash_with_prefix: bytes): A secret is the segment to follow the `enc2:::`. """ + secret = None hash_with_prefix = safe_encode_utf8(hash_with_prefix) - hashed_value = hash_with_prefix[len(HASH_PREFIX) :][: self.hash_size] or None - secret = self.cipher_buffer[self.cipher_buffer_key].get(hashed_value) + if hashed_value := hash_with_prefix[len(HASH_PREFIX) :][: self.hash_size] or None: + secret = cache.get(self.cipher_buffer_key + hashed_value, None) + # secret = self.cipher_buffer[self.cipher_buffer_key].get(hashed_value) if not secret: try: data = ( @@ -172,34 +178,28 @@ def fetch_secret(self, hash_with_prefix: bytes): .values("secret") .get(hash=hashed_value, algorithm=self.algorithm, mode=self.access_mode) ) - secret = data.get("secret") - self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) except ObjectDoesNotExist: raise EncryptionError( f"EncryptionError. Failed to get secret for given {self.algorithm} " f"{self.access_mode} hash. Got '{str(hash_with_prefix)}'" ) + else: + secret = data.get("secret") + cache.set(self.cipher_buffer_key + hashed_value, secret) + # self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) return secret - def is_encrypted(self, value: str | bytes | None) -> bool: + @staticmethod + def is_encrypted(value: str | bytes | None) -> bool: """Returns True if value is encrypted. An encrypted value starts with the hash_prefix. - - Inspects a value that is: - * a string value -> False - * a well-formed hash - * a well-formed hash_prefix + hash -> True - * a well-formed hash + secret. """ - is_encrypted = False if value is not None: value = safe_encode_utf8(value) if value.startswith(safe_encode_utf8(HASH_PREFIX)): - p = CipherParser(value, self.salt_key) - p.validate_hashed_value() - is_encrypted = True - return is_encrypted + return True + return False def mask(self, value, mask=None): """Returns 'mask' if value is encrypted.""" diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 919384d..3c8f4e8 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -7,7 +7,6 @@ from django.apps import apps as django_apps from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from .constants import CIPHER_PREFIX, ENCODING, HASH_ALGORITHM, HASH_PREFIX, HASH_ROUNDS from .exceptions import MalformedCiphertextError @@ -43,19 +42,7 @@ def get_crypt_model() -> str: def get_crypt_model_cls() -> Type[Crypt]: - """Return the Crypt model that is active in this project.""" - try: - return django_apps.get_model(get_crypt_model(), require_ready=False) - except ValueError: - raise ImproperlyConfigured( - "Invalid. `settings.DJANGO_CRYPTO_FIELDS_MODEL` must refer to a model " - f"using lower_label format. Got {get_crypt_model()}." - ) - except LookupError: - raise ImproperlyConfigured( - "Invalid. `settings.DJANGO_CRYPTO_FIELDS_MODEL` refers to a model " - f"that has not been installed. Got {get_crypt_model()}." - ) + return django_apps.get_model(get_crypt_model()) def get_auto_create_keys_from_settings() -> bool: From 3eee158eabe40c50fd71877567fd2e93f463e040 Mon Sep 17 00:00:00 2001 From: erikvw Date: Wed, 20 Mar 2024 23:23:25 -0500 Subject: [PATCH 23/25] make cache prefix key more specific, remove some value checks in methods/func involved in the encrypt, decrypt flow --- django_crypto_fields/cipher/cipher.py | 8 +- django_crypto_fields/cryptor.py | 92 +++------ django_crypto_fields/field_cryptor.py | 194 +++++++++-------- django_crypto_fields/fields/base_aes_field.py | 2 + django_crypto_fields/fields/base_field.py | 39 ++-- django_crypto_fields/fields/base_rsa_field.py | 2 + .../fields/encrypted_char_field.py | 2 + .../fields/encrypted_decimal_field.py | 2 + .../fields/encrypted_integer_field.py | 2 + .../fields/encrypted_text_field.py | 2 + .../fields/firstname_field.py | 2 + django_crypto_fields/fields/identity_field.py | 2 + django_crypto_fields/fields/lastname_field.py | 2 + .../key_path/persist_key_path_or_raise.py | 4 +- django_crypto_fields/keys/utils.py | 8 + .../migrations/0006_auto_20240321_0411.py | 24 +++ .../templatetags/crypto_tags.py | 10 +- .../tests/crypto_keys/django_crypto_fields | 2 +- .../tests/tests/test_field_cryptor.py | 195 +++++++++++------- django_crypto_fields/utils.py | 45 +++- 20 files changed, 385 insertions(+), 254 deletions(-) create mode 100644 django_crypto_fields/migrations/0006_auto_20240321_0411.py diff --git a/django_crypto_fields/cipher/cipher.py b/django_crypto_fields/cipher/cipher.py index cd3b445..7bfd56a 100644 --- a/django_crypto_fields/cipher/cipher.py +++ b/django_crypto_fields/cipher/cipher.py @@ -10,9 +10,13 @@ class Cipher: """A class that given a value builds a cipher of the format - hash_prefix + hashed_value + cipher_prefix + secret. + hash_prefix + hashed_value + cipher_prefix + secret. + . + For example: + enc1:::234234ed234a24enc2::\x0e\xb9\xae\x13s\x8d + \xe7O\xbb\r\x99. - The secret is encrypted using the passed `encrypt` callable. + The secret is encrypted using the passed `encrypt` callable. """ def __init__( diff --git a/django_crypto_fields/cryptor.py b/django_crypto_fields/cryptor.py index bc701fb..d7ffeb9 100644 --- a/django_crypto_fields/cryptor.py +++ b/django_crypto_fields/cryptor.py @@ -1,20 +1,26 @@ from __future__ import annotations -import binascii from typing import TYPE_CHECKING from Cryptodome import Random from Cryptodome.Cipher import AES as AES_CIPHER -from .constants import AES, ENCODING, LOCAL_MODE, PRIVATE, PUBLIC, RSA +from .constants import AES, ENCODING, PRIVATE, PUBLIC, RSA from .exceptions import EncryptionError from .keys import encryption_keys -from .utils import get_keypath_from_settings +from .utils import ( + append_padding, + get_keypath_from_settings, + remove_padding, + safe_encode_utf8, +) if TYPE_CHECKING: from Cryptodome.Cipher._mode_cbc import CbcMode from Cryptodome.Cipher.PKCS1_OAEP import PKCS1OAEP_Cipher +__all__ = ["Cryptor"] + class Cryptor: """Base class for all classes providing RSA and AES encryption @@ -24,7 +30,7 @@ class Cryptor: of this except the filenames are replaced with the actual keys. """ - def __init__(self, algorithm: AES | RSA, access_mode: PRIVATE | LOCAL_MODE = None) -> None: + def __init__(self, algorithm, access_mode) -> None: self.algorithm = algorithm self.aes_encryption_mode: int = AES_CIPHER.MODE_CBC aes_key_attr: str = "_".join([AES, access_mode, PRIVATE, "key"]) @@ -36,77 +42,35 @@ def __init__(self, algorithm: AES | RSA, access_mode: PRIVATE | LOCAL_MODE = Non self.encrypt = getattr(self, f"_{self.algorithm.lower()}_encrypt") self.decrypt = getattr(self, f"_{self.algorithm.lower()}_decrypt") - def get_with_padding(self, plaintext: str | bytes, block_size: int) -> bytes: - """Return string padded so length is a multiple of the block size. - * store length of padding the last hex value. - * if padding is 0, pad as if padding is 16. - * AES_CIPHER.MODE_CFB should not be used, but was used - without padding in the past. Continue to skip padding - for this mode. - """ - try: - plaintext = plaintext.encode(ENCODING) - except AttributeError: - pass - if self.aes_encryption_mode == AES_CIPHER.MODE_CFB: - padding_length = 0 - else: - padding_length = (block_size - len(plaintext) % block_size) % block_size - padding_length = padding_length or 16 - padded = ( - plaintext - + (b"\x00" * (padding_length - 1)) - + binascii.a2b_hex(str(padding_length).zfill(2)) - ) - if len(padded) % block_size > 0: - multiple = len(padded) / block_size - raise EncryptionError( - f"Padding error, got padded string not a multiple " - f"of {block_size}. Got {multiple}" - ) - return padded - - def get_without_padding(self, plaintext: str | bytes) -> bytes: - """Return original plaintext without padding. - - Length of padding is stored in last two characters of - plaintext. - """ - if self.aes_encryption_mode == AES_CIPHER.MODE_CFB: - return plaintext - padding_length = int(binascii.b2a_hex(plaintext[-1:])) - if not padding_length: - return plaintext[:-1] - return plaintext[:-padding_length] - - def _aes_encrypt(self, plaintext: str | bytes) -> bytes: + def _aes_encrypt(self, value: str | bytes) -> bytes: + encoded_value = safe_encode_utf8(value) iv: bytes = Random.new().read(AES_CIPHER.block_size) cipher: CbcMode = AES_CIPHER.new(self.aes_key, self.aes_encryption_mode, iv) - padded_plaintext = self.get_with_padding(plaintext, cipher.block_size) - return iv + cipher.encrypt(padded_plaintext) + encoded_value = append_padding(encoded_value, cipher.block_size) + secret = iv + cipher.encrypt(encoded_value) + return secret - def _aes_decrypt(self, ciphertext: bytes) -> str: - iv = ciphertext[: AES_CIPHER.block_size] + def _aes_decrypt(self, secret: bytes) -> str: + iv = secret[: AES_CIPHER.block_size] cipher: CbcMode = AES_CIPHER.new(self.aes_key, self.aes_encryption_mode, iv) - plaintext = cipher.decrypt(ciphertext)[AES_CIPHER.block_size :] - return self.get_without_padding(plaintext).decode() + encoded_value = cipher.decrypt(secret)[AES_CIPHER.block_size :] + encoded_value = remove_padding(encoded_value) + value = encoded_value.decode() + return value - def _rsa_encrypt(self, plaintext: str | bytes) -> bytes: - try: - plaintext = plaintext.encode(ENCODING) - except AttributeError: - pass + def _rsa_encrypt(self, value: str | bytes) -> bytes: + encoded_value = safe_encode_utf8(value) try: - ciphertext = self.rsa_public_key.encrypt(plaintext) + secret = self.rsa_public_key.encrypt(encoded_value) except (ValueError, TypeError) as e: raise EncryptionError(f"RSA encryption failed for value. Got '{e}'") - return ciphertext + return secret - def _rsa_decrypt(self, ciphertext: bytes) -> str: + def _rsa_decrypt(self, secret: bytes) -> str: try: - plaintext = self.rsa_private_key.decrypt(ciphertext) + encoded_value = self.rsa_private_key.decrypt(secret) except ValueError as e: raise EncryptionError( f"{e} Using RSA from key_path=`{get_keypath_from_settings()}`." ) - return plaintext.decode(ENCODING) + return encoded_value.decode(ENCODING) diff --git a/django_crypto_fields/field_cryptor.py b/django_crypto_fields/field_cryptor.py index e4c034f..8b91857 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -1,21 +1,28 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Type - from Cryptodome.Cipher import AES as AES_CIPHER from django.apps import apps as django_apps from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from .cipher import Cipher -from .constants import AES, CIPHER_PREFIX, ENCODING, HASH_PREFIX, PRIVATE, RSA, SALT +from .constants import ( + AES, + CIPHER_PREFIX, + ENCODING, + HASH_PREFIX, + LOCAL_MODE, + PRIVATE, + RESTRICTED_MODE, + RSA, + SALT, +) from .cryptor import Cryptor from .exceptions import EncryptionError, EncryptionKeyError, InvalidEncryptionAlgorithm from .keys import encryption_keys from .utils import get_crypt_model_cls, make_hash, safe_decode, safe_encode_utf8 -if TYPE_CHECKING: - from .models import Crypt +__all__ = ["FieldCryptor"] class FieldCryptor: @@ -30,16 +37,15 @@ class FieldCryptor: decrypted and returned to the user's model field object. """ - crypt_model = "django_crypto_fields.crypt" cryptor_cls = Cryptor cipher_cls = Cipher def __init__(self, algorithm: str, access_mode: str): self._using = None self._algorithm = None + self._access_mode = None self.algorithm = algorithm self.access_mode = access_mode - self.aes_encryption_mode = AES_CIPHER.MODE_CBC self.cipher_buffer_key = b"{self.algorithm}_{self.access_mode}" self.cipher_buffer = {self.cipher_buffer_key: {}} self.keys = encryption_keys @@ -50,18 +56,31 @@ def __repr__(self) -> str: return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.access_mode}')" @property - def algorithm(self): + def algorithm(self) -> str: return self._algorithm @algorithm.setter - def algorithm(self, value): + def algorithm(self, value: str): self._algorithm = value if value not in [AES, RSA]: raise InvalidEncryptionAlgorithm( f"Invalid encryption algorithm. Expected 'aes' or 'rsa'. Got {value}" ) - def hash(self, value): + @property + def access_mode(self) -> str: + return self._access_mode + + @access_mode.setter + def access_mode(self, value: str): + self._access_mode = value + if value not in [LOCAL_MODE, PRIVATE, RESTRICTED_MODE]: + raise InvalidEncryptionAlgorithm( + "Invalid encryption access mode. Expected " + f"'{LOCAL_MODE}' or '{PRIVATE}' or {RESTRICTED_MODE}. Got {value}." + ) + + def hash(self, value) -> bytes: return make_hash(value, self.salt_key) @property @@ -73,46 +92,43 @@ def salt_key(self): raise EncryptionKeyError(f"Invalid key. Got {attr}. {e}") return salt - @property - def crypt_model_cls(self) -> Type[Crypt]: - """Returns the cipher model and avoids issues with model - loading and field classes. - """ - return get_crypt_model_cls() + def encrypt(self, value: bytes | None, update: bool | None = None) -> bytes: + """Returns either an RSA or AES cipher of the format + hash_prefix + hashed_value + cipher_prefix + secret. - def encrypt(self, value: str | bytes | None, update: bool | None = None): - """Returns either an RSA or AES cipher. + * 'value' may or may not be encoded + * 'update' if True updates the value in the Crypt model - * 'value' is either plaintext or ciphertext - * 'cipher' is a byte value of hash_prefix - + hashed_value + cipher_prefix + secret. - For example: - enc1:::234234ed234a24enc2::\x0e\xb9\xae\x13s\x8d - \xe7O\xbb\r\x99. + * `cipher.cipher` instance formats the cipher. For example: + enc1:::234234ed234a24enc2::\x0e\xb9\xae\x13s\x8d\xe7O\xbb\r\x99. * 'value' is not re-encrypted if already encrypted and properly - formatted 'ciphertext'. + formatted `cipher.cipher` byte value. """ cipher = None update = True if update is None else update encoded_value = safe_encode_utf8(value) if encoded_value and not self.is_encrypted(encoded_value): - cipher = self.cipher_cls(value, self.salt_key, encrypt=self.cryptor.encrypt) + cipher = self.cipher_cls( + encoded_value, self.salt_key, encrypt=self.cryptor.encrypt + ) if update: self.update_crypt(cipher) return getattr(cipher, "cipher", encoded_value) - def decrypt(self, hash_with_prefix: str | bytes): + def decrypt(self, hash_with_prefix: bytes) -> str | None: """Returns decrypted secret or None. + Will raise a TypeError if `hash_with_prefix` is empty. + Secret is retrieved from `Crypt` using the hash_with_prefix coming from the field of the user model. - hash_with_prefix = hash_prefix+hash_value. + hash_with_prefix:bytes = hash_prefix + hash_value. + + See also BaseField.from_db_value. """ - hash_with_prefix = safe_encode_utf8(hash_with_prefix) - if hash_with_prefix and self.is_encrypted(hash_with_prefix): - if secret := self.fetch_secret(hash_with_prefix): - return self.cryptor.decrypt(secret) + if secret := self.fetch_secret(hash_with_prefix): + return self.cryptor.decrypt(secret) return None @property @@ -122,89 +138,95 @@ def using(self): self._using = app_config.crypt_model_using return self._using - def update_crypt(self, cipher: Cipher): - """Updates Crypt model and cipher_buffer.""" - try: - crypt = self.crypt_model_cls.objects.using(self.using).get( - hash=cipher.hashed_value, algorithm=self.algorithm, mode=self.access_mode - ) - except ObjectDoesNotExist: - self.crypt_model_cls.objects.using(self.using).create( - hash=cipher.hashed_value, - secret=cipher.secret, - algorithm=self.algorithm, - cipher_mode=self.aes_encryption_mode, - mode=self.access_mode, + @property + def cache_key_prefix(self) -> bytes: + algorithm = safe_encode_utf8(self.algorithm) + access_mode = safe_encode_utf8(self.access_mode) + return b"django-crypto-fields-" + algorithm + b"-" + access_mode + b"-" + + def update_crypt(self, cipher: Cipher) -> None: + """Updates Crypt model and the cache. + + `hash_value` is stored as a string to make use of the + unique constraint on field `hash`. + """ + opts = dict( + hash=cipher.hashed_value.decode(), + algorithm=self.algorithm, + mode=self.access_mode, + cipher_mode=AES_CIPHER.MODE_CBC, + ) + if not get_crypt_model_cls().objects.using(self.using).filter(**opts).exists(): + get_crypt_model_cls().objects.using(self.using).create( + secret=cipher.secret, **opts ) - else: - crypt.secret = cipher.secret - crypt.save() - cache.set(self.cipher_buffer_key + cipher.hashed_value, cipher.secret) - # self.cipher_buffer[self.cipher_buffer_key].update( - # {cipher.hashed_value: cipher.secret}) + cache.set(self.cache_key_prefix + cipher.hashed_value, cipher.secret) def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: - """Returns the prefix + hash_value as stored in the DB table column of - your model's "encrypted" field. + """Returns the prefix + hash_value, an empty string, or None + as stored in the DB table column of your model's "encrypted" + field. - Used by get_prep_value() + Used by field_cls.get_prep_value() """ hash_with_prefix = None - if value is None or value in ["", b""]: - pass # return None or empty string/byte + encoded_value = safe_encode_utf8(value) + if encoded_value == b"": + encoded_value = "" + elif encoded_value is None: + pass else: - cipher = self.encrypt(value) + cipher = self.encrypt(encoded_value) hash_with_prefix = cipher.split(CIPHER_PREFIX.encode(ENCODING))[0] hash_with_prefix = safe_decode(hash_with_prefix) - return hash_with_prefix or value + return hash_with_prefix or encoded_value def fetch_secret(self, hash_with_prefix: bytes) -> bytes | None: """Fetch the secret from the DB or the buffer using the hashed_value as the lookup. - If not found in buffer, lookup in DB and update the buffer. + If not found in cache, lookup in DB and update the cache. A secret is the segment to follow the `enc2:::`. """ secret = None hash_with_prefix = safe_encode_utf8(hash_with_prefix) if hashed_value := hash_with_prefix[len(HASH_PREFIX) :][: self.hash_size] or None: - secret = cache.get(self.cipher_buffer_key + hashed_value, None) - # secret = self.cipher_buffer[self.cipher_buffer_key].get(hashed_value) - if not secret: - try: - data = ( - self.crypt_model_cls.objects.using(self.using) - .values("secret") - .get(hash=hashed_value, algorithm=self.algorithm, mode=self.access_mode) - ) - except ObjectDoesNotExist: - raise EncryptionError( - f"EncryptionError. Failed to get secret for given {self.algorithm} " - f"{self.access_mode} hash. Got '{str(hash_with_prefix)}'" - ) - else: - secret = data.get("secret") - cache.set(self.cipher_buffer_key + hashed_value, secret) - # self.cipher_buffer[self.cipher_buffer_key].update({hashed_value: secret}) + secret = cache.get(self.cache_key_prefix + hashed_value, None) + if not secret: + try: + data = ( + get_crypt_model_cls() + .objects.using(self.using) + .values("secret") + .get( + hash=hashed_value.decode(), + algorithm=self.algorithm, + mode=self.access_mode, + ) + ) + except ObjectDoesNotExist: + raise EncryptionError( + f"EncryptionError. Failed to get secret for given {self.algorithm} " + f"{self.access_mode} hash. Got '{str(hash_with_prefix)}'" + ) + else: + secret = data.get("secret") + cache.set(self.cache_key_prefix + hashed_value, secret) return secret @staticmethod - def is_encrypted(value: str | bytes | None) -> bool: + def is_encrypted(value: bytes | None) -> bool: """Returns True if value is encrypted. An encrypted value starts with the hash_prefix. """ - if value is not None: - value = safe_encode_utf8(value) - if value.startswith(safe_encode_utf8(HASH_PREFIX)): - return True + encoded_value = safe_encode_utf8(value) + if encoded_value and encoded_value.startswith(safe_encode_utf8(HASH_PREFIX)): + return True return False def mask(self, value, mask=None): """Returns 'mask' if value is encrypted.""" mask = mask or "" - if self.is_encrypted(value): - return mask - else: - return value + return mask if self.is_encrypted(value) else value diff --git a/django_crypto_fields/fields/base_aes_field.py b/django_crypto_fields/fields/base_aes_field.py index 7281d0d..2d44b23 100644 --- a/django_crypto_fields/fields/base_aes_field.py +++ b/django_crypto_fields/fields/base_aes_field.py @@ -1,6 +1,8 @@ from ..constants import AES, LOCAL_MODE from .base_field import BaseField +__all__ = ["BaseAesField"] + class BaseAesField(BaseField): def __init__(self, *args, **kwargs): diff --git a/django_crypto_fields/fields/base_field.py b/django_crypto_fields/fields/base_field.py index d5392f7..9e00c7f 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING from django.conf import settings -from django.core.management.color import color_style from django.db import models from django.forms import widgets @@ -15,11 +14,12 @@ ) from ..field_cryptor import FieldCryptor from ..keys import encryption_keys +from ..utils import safe_encode_utf8 if TYPE_CHECKING: from ..keys import Keys -style = color_style() +__all__ = ["BaseField"] class BaseField(models.Field): @@ -57,6 +57,12 @@ def __init__(self, algorithm: str, access_mode: str, *args, **kwargs): kwargs.setdefault("blank", True) super().__init__(*args, **kwargs) + def get_internal_type(self): + """This is a `CharField` as we only ever store the + hash_prefix + hash, which is a fixed length char. + """ + return "CharField" + def deconstruct(self): name, path, args, kwargs = super(BaseField, self).deconstruct() kwargs["help_text"] = self.help_text @@ -74,23 +80,16 @@ def formfield(self, **kwargs): defaults.update(kwargs) return super(BaseField, self).formfield(**defaults) - def decrypt(self, value): - if value is None or value in ["", b""]: - decrypted_value = value - else: - decrypted_value = self.field_cryptor.decrypt(value) - return decrypted_value - - def from_db_value(self, value, *args): - if value is None or value in ["", b""]: - return value - return self.decrypt(value) + def from_db_value(self, value: bytes | None, *args) -> bytes | str | None: + """Returns the decrypted value, an empty string, or None.""" + value = safe_encode_utf8(value) + if value == b"": + return "" + return self.field_cryptor.decrypt(value) if value else None def get_prep_value(self, value): - """Returns the encrypted value, including prefix, as the - query value (to query the db). - - db is queried using the hash + """Returns prefix + hash_value, an empty string, or None + for use as a parameter in a query. Note: partial matches do not work. See get_prep_lookup(). """ @@ -133,11 +132,5 @@ def get_in_as_lookup(self, values): hashed_values.append(HASH_PREFIX.encode(ENCODING) + self.field_cryptor.hash(value)) return hashed_values - def get_internal_type(self): - """This is a `CharField` as we only ever store the hash, - which is a fixed length char. - """ - return "CharField" - def mask(self, value, mask=None): return self.field_cryptor.mask(value, mask) diff --git a/django_crypto_fields/fields/base_rsa_field.py b/django_crypto_fields/fields/base_rsa_field.py index cf4c3f6..203e19a 100644 --- a/django_crypto_fields/fields/base_rsa_field.py +++ b/django_crypto_fields/fields/base_rsa_field.py @@ -1,6 +1,8 @@ from ..constants import LOCAL_MODE, RSA from .base_field import BaseField +__all__ = ["BaseRsaField"] + class BaseRsaField(BaseField): def __init__(self, *args, **kwargs): diff --git a/django_crypto_fields/fields/encrypted_char_field.py b/django_crypto_fields/fields/encrypted_char_field.py index c4c3f03..a4e297e 100644 --- a/django_crypto_fields/fields/encrypted_char_field.py +++ b/django_crypto_fields/fields/encrypted_char_field.py @@ -1,5 +1,7 @@ from .base_rsa_field import BaseRsaField +__all__ = ["EncryptedCharField"] + class EncryptedCharField(BaseRsaField): description = "rsa encrypted field for 'CharField'" diff --git a/django_crypto_fields/fields/encrypted_decimal_field.py b/django_crypto_fields/fields/encrypted_decimal_field.py index 2508003..b7f1c54 100644 --- a/django_crypto_fields/fields/encrypted_decimal_field.py +++ b/django_crypto_fields/fields/encrypted_decimal_field.py @@ -2,6 +2,8 @@ from .base_rsa_field import BaseRsaField +__all__ = ["EncryptedDecimalField"] + class EncryptedDecimalField(BaseRsaField): description = "local-rsa encrypted field for 'IntegerField'" diff --git a/django_crypto_fields/fields/encrypted_integer_field.py b/django_crypto_fields/fields/encrypted_integer_field.py index 5ce36e9..3a5f8bd 100644 --- a/django_crypto_fields/fields/encrypted_integer_field.py +++ b/django_crypto_fields/fields/encrypted_integer_field.py @@ -1,5 +1,7 @@ from .base_rsa_field import BaseRsaField +__all__ = ["EncryptedIntegerField"] + class EncryptedIntegerField(BaseRsaField): description = "local-rsa encrypted field for 'IntegerField'" diff --git a/django_crypto_fields/fields/encrypted_text_field.py b/django_crypto_fields/fields/encrypted_text_field.py index 1d87cce..8b2ede5 100644 --- a/django_crypto_fields/fields/encrypted_text_field.py +++ b/django_crypto_fields/fields/encrypted_text_field.py @@ -2,6 +2,8 @@ from .base_aes_field import BaseAesField +__all__ = ["EncryptedTextField"] + class EncryptedTextField(BaseAesField): description = "Custom field for 'Text' form field, uses local AES" diff --git a/django_crypto_fields/fields/firstname_field.py b/django_crypto_fields/fields/firstname_field.py index 2dc26bd..fd19bac 100644 --- a/django_crypto_fields/fields/firstname_field.py +++ b/django_crypto_fields/fields/firstname_field.py @@ -1,5 +1,7 @@ from .base_rsa_field import BaseRsaField +__all__ = ["FirstnameField"] + class FirstnameField(BaseRsaField): """Restricted-rsa encrypted field for a model's Firstname diff --git a/django_crypto_fields/fields/identity_field.py b/django_crypto_fields/fields/identity_field.py index eb63cc6..140f65c 100644 --- a/django_crypto_fields/fields/identity_field.py +++ b/django_crypto_fields/fields/identity_field.py @@ -1,5 +1,7 @@ from .base_rsa_field import BaseRsaField +__all__ = ["IdentityField"] + class IdentityField(BaseRsaField): def __init__(self, *args, **kwargs): diff --git a/django_crypto_fields/fields/lastname_field.py b/django_crypto_fields/fields/lastname_field.py index 3be8b8a..bbc94b8 100644 --- a/django_crypto_fields/fields/lastname_field.py +++ b/django_crypto_fields/fields/lastname_field.py @@ -1,5 +1,7 @@ from .base_rsa_field import BaseRsaField +__all__ = ["LastnameField"] + class LastnameField(BaseRsaField): pass diff --git a/django_crypto_fields/key_path/persist_key_path_or_raise.py b/django_crypto_fields/key_path/persist_key_path_or_raise.py index 4178993..5ae7db6 100644 --- a/django_crypto_fields/key_path/persist_key_path_or_raise.py +++ b/django_crypto_fields/key_path/persist_key_path_or_raise.py @@ -14,8 +14,6 @@ __all__ = ["persist_key_path_or_raise"] -style = color_style() - def persist_key_path_or_raise() -> None: expected_folder: Path = Path(KeyPath().path) @@ -23,6 +21,7 @@ def persist_key_path_or_raise() -> None: if not last_used_folder: last_used_folder = write_last_used(filepath) if last_used_folder != expected_folder: + style = color_style() raise DjangoCryptoFieldsKeyPathChangeError( style.ERROR( "Key path changed since last startup! You must resolve " @@ -56,6 +55,7 @@ def read_last_used(folder: Path) -> tuple[PurePath | None, Path]: last_used_path = PurePath(row.get("path")) break if last_used_path and not Path(last_used_path).exists(): + style = color_style() raise DjangoCryptoFieldsKeyPathError( style.ERROR( "Last path used to access encryption keys is invalid. " diff --git a/django_crypto_fields/keys/utils.py b/django_crypto_fields/keys/utils.py index 84b4c88..a3dfab6 100644 --- a/django_crypto_fields/keys/utils.py +++ b/django_crypto_fields/keys/utils.py @@ -6,6 +6,14 @@ from ..constants import AES, LOCAL_MODE, PRIVATE, PUBLIC, RESTRICTED_MODE, RSA, SALT +__all__ = [ + "get_template", + "get_filenames", + "key_files_exist", + "write_msg", + "get_values_from_nested_dict", +] + def get_template(path: PurePath, key_prefix: str) -> dict[str, dict[str, dict[str, PurePath]]]: """Returns the data structure to store encryption keys. diff --git a/django_crypto_fields/migrations/0006_auto_20240321_0411.py b/django_crypto_fields/migrations/0006_auto_20240321_0411.py new file mode 100644 index 0000000..415ac04 --- /dev/null +++ b/django_crypto_fields/migrations/0006_auto_20240321_0411.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.3 on 2024-03-21 01:11 +from django.db import migrations +from tqdm import tqdm + +from django_crypto_fields.utils import get_crypt_model_cls + + +def fix_hash_value(apps, schema_editor): + crypt_cls = get_crypt_model_cls() + qs = crypt_cls.objects.all() + total = qs.count() + for obj in tqdm(crypt_cls.objects.all(), total=total): + if obj.hash.startswith("b'") and obj.hash.endswith("'"): + obj.hash = obj.hash[2:][:-1] + obj.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("django_crypto_fields", "0005_crypt_locale_created_crypt_locale_modified"), + ] + + operations = [migrations.RunPython(fix_hash_value)] diff --git a/django_crypto_fields/templatetags/crypto_tags.py b/django_crypto_fields/templatetags/crypto_tags.py index 410d795..723206d 100644 --- a/django_crypto_fields/templatetags/crypto_tags.py +++ b/django_crypto_fields/templatetags/crypto_tags.py @@ -1,14 +1,12 @@ from django import template +from ..constants import LOCAL_MODE, RSA from ..field_cryptor import FieldCryptor register = template.Library() @register.filter(name="encrypted") -def encrypted(value): - retval = value - field_cryptor = FieldCryptor("rsa", "local") - if field_cryptor.is_encrypted(value): - retval = field_cryptor.mask(value) - return retval +def encrypted(value: str): + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + return field_cryptor.mask(value) diff --git a/django_crypto_fields/tests/crypto_keys/django_crypto_fields b/django_crypto_fields/tests/crypto_keys/django_crypto_fields index 7eb33c3..bf0bfe5 100644 --- a/django_crypto_fields/tests/crypto_keys/django_crypto_fields +++ b/django_crypto_fields/tests/crypto_keys/django_crypto_fields @@ -1,2 +1,2 @@ path,date -/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-20 06:26:42.256762+00:00 +/Users/erikvw/source/edc_source/django-crypto-fields/django_crypto_fields/tests/crypto_keys,2024-03-21 03:44:41.226908+00:00 diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py index a45d801..8814967 100644 --- a/django_crypto_fields/tests/tests/test_field_cryptor.py +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -8,7 +8,11 @@ from django_crypto_fields.exceptions import MalformedCiphertextError from django_crypto_fields.field_cryptor import FieldCryptor from django_crypto_fields.keys import encryption_keys -from django_crypto_fields.utils import has_valid_hash_or_raise +from django_crypto_fields.utils import ( + get_crypt_model_cls, + has_valid_hash_or_raise, + safe_encode_utf8, +) from ..models import TestModel @@ -73,7 +77,6 @@ def test_verify_hashed_value(self): except MalformedCiphertextError: self.fail("MalformedCiphertextError unexpectedly raised") - @tag("6") def test_verify_is_encrypted(self): field_cryptor = FieldCryptor(RSA, LOCAL_MODE) value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( @@ -103,127 +106,180 @@ def test_rsa_field_encryption(self): self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) def test_rsa_field_encryption_update_secret(self): - """Assert successful AES field roundtrip for same value.""" - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" + """Assert successful RSA field roundtrip for same value.""" + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) for mode in encryption_keys.get(RSA): field_cryptor = FieldCryptor(RSA, mode) - ciphertext1 = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext1)) - ciphertext2 = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext2)) - self.assertFalse(ciphertext1 == ciphertext2) + cipher1 = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(cipher1)) + cipher2 = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(cipher2)) + self.assertFalse(cipher1 == cipher2) def test_aes_field_encryption(self): """Assert successful RSA field roundtrip.""" - plaintext = "erik is a pleeb!!" + value = "erik is a pleeb!!" + encoded_value = safe_encode_utf8(value) for mode in encryption_keys.get(AES): field_cryptor = FieldCryptor(AES, mode) - ciphertext = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) + ciphertext = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext)) def test_rsa_field_encryption_encoded(self): """Assert successful RSA field roundtrip.""" - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) for mode in encryption_keys.get(RSA): field_cryptor = FieldCryptor(RSA, mode) - ciphertext = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) + ciphertext = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext)) def test_aes_field_encryption_encoded(self): """Assert successful AES field roundtrip.""" - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) for mode in encryption_keys.get(AES): field_cryptor = FieldCryptor(AES, mode) - ciphertext = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) + ciphertext = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext)) def test_aes_field_encryption_update_secret(self): """Assert successful AES field roundtrip for same value.""" - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) for mode in encryption_keys.get(AES): field_cryptor = FieldCryptor(AES, mode) - ciphertext1 = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext1)) - ciphertext2 = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext2)) + ciphertext1 = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext1)) + ciphertext2 = field_cryptor.encrypt(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext2)) self.assertFalse(ciphertext1 == ciphertext2) def test_rsa_update_crypt_model(self): """Asserts plaintext can be encrypted, saved to model, retrieved by hash, and decrypted. """ - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - hashed_value = field_cryptor.hash(plaintext) - field_cryptor.encrypt(plaintext, update=True) - secret = field_cryptor.crypt_model_cls.objects.get(hash=hashed_value).secret + hashed_value = field_cryptor.hash(encoded_value) + field_cryptor.encrypt(encoded_value, update=True) + secret = get_crypt_model_cls().objects.get(hash=hashed_value.decode()).secret field_cryptor.fetch_secret(HASH_PREFIX.encode(ENCODING) + hashed_value) - self.assertEqual(plaintext, cryptor.decrypt(secret)) + self.assertEqual(value, cryptor.decrypt(secret)) def test_aes_update_crypt_model(self): """Asserts plaintext can be encrypted, saved to model, retrieved by hash, and decrypted. """ - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) field_cryptor = FieldCryptor(AES, LOCAL_MODE) - field_cryptor.encrypt(plaintext, update=True) - hashed_value = field_cryptor.hash(plaintext) - secret = field_cryptor.crypt_model_cls.objects.get(hash=hashed_value).secret + field_cryptor.encrypt(encoded_value, update=True) + hashed_value = field_cryptor.hash(encoded_value) + secret = get_crypt_model_cls().objects.get(hash=hashed_value.decode()).secret field_cryptor.fetch_secret(HASH_PREFIX.encode(ENCODING) + hashed_value) - self.assertEqual(plaintext, field_cryptor.cryptor.decrypt(secret)) + self.assertEqual(encoded_value.decode(), field_cryptor.cryptor.decrypt(secret)) - @tag("3") - def test_get_secret(self): - """Asserts secret is returned either as None or the secret.""" + def test_none_value_is_not_added_to_crypt_model(self): + self.assertEqual(get_crypt_model_cls().objects.all().count(), 0) field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = None + cipher = field_cryptor.encrypt(value, update=True) + p = CipherParser(cipher) + self.assertIsNone(p.secret) + self.assertIsNone(p.hash_prefix) + self.assertEqual(get_crypt_model_cls().objects.all().count(), 0) - plaintext = None - cipher = field_cryptor.encrypt(plaintext, update=True) - secret = CipherParser(cipher).secret - self.assertIsNone(secret) - self.assertEqual(plaintext, field_cryptor.decrypt(secret)) + def test_empty_value_is_not_added_to_crypt_model(self): + self.assertEqual(get_crypt_model_cls().objects.all().count(), 0) + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = b"" + cipher = field_cryptor.encrypt(value, update=True) + p = CipherParser(cipher) + self.assertIsNone(p.secret) + self.assertIsNone(p.hash_prefix) + self.assertEqual(get_crypt_model_cls().objects.all().count(), 0) - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç" - cipher = field_cryptor.encrypt(plaintext, update=True) - cipher = CipherParser(cipher) - self.assertIsNotNone(cipher.secret) + def test_get_secret(self): + self.assertEqual(get_crypt_model_cls().objects.all().count(), 0) + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) + cipher = field_cryptor.encrypt(encoded_value, update=True) + p = CipherParser(cipher) + self.assertIsNotNone(p.secret) self.assertEqual( - plaintext, field_cryptor.decrypt(cipher.hash_prefix + cipher.hashed_value) + encoded_value.decode(), field_cryptor.decrypt(p.hash_prefix + p.hashed_value) ) + self.assertEqual(get_crypt_model_cls().objects.all().count(), 1) + + def test_rsa_field_as_none_raises(self): + """Asserts RSA cannot roundtrip on None.""" + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = None + cipher = field_cryptor.encrypt(value) + self.assertRaises(TypeError, field_cryptor.decrypt, cipher) + + def test_aes_field_as_none_raises(self): + """Asserts AES cannot roundtrip on None.""" + field_cryptor = FieldCryptor(AES, LOCAL_MODE) + value = None + cipher = field_cryptor.encrypt(value) + self.assertRaises(TypeError, field_cryptor.decrypt, cipher) - def test_rsa_field_as_none(self): - """Asserts RSA roundtrip on None.""" + def test_rsa_field_as_empty(self): + """Asserts RSA cannot roundtrip on None.""" field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - plaintext = None - ciphertext = field_cryptor.encrypt(plaintext) - self.assertIsNone(field_cryptor.decrypt(ciphertext)) + value = b"" + cipher = field_cryptor.encrypt(value) + self.assertIsNone(field_cryptor.decrypt(cipher)) - def test_aes_field_as_none(self): - """Asserts AES roundtrip on None.""" + def test_aes_field_as_empty(self): + """Asserts AES cannot roundtrip on None.""" field_cryptor = FieldCryptor(AES, LOCAL_MODE) - plaintext = None - ciphertext = field_cryptor.encrypt(plaintext) - self.assertIsNone(field_cryptor.decrypt(ciphertext)) + value = b"" + cipher = field_cryptor.encrypt(value) + self.assertIsNone(field_cryptor.decrypt(cipher)) + @tag("6") def test_model_with_encrypted_fields(self): """Asserts roundtrip via a model with encrypted fields.""" - firstname = "erik" - identity = "123456789" - comment = "erik is a pleeb!!∂ƒ˜∫˙ç" - test_model = TestModel.objects.create( - firstname=firstname, identity=identity, comment=comment + data = dict( + firstname="erik", + identity="123456789", + comment="erik is a pleeb!!∂ƒ˜∫˙ç", ) - self.assertEqual(test_model.firstname, firstname) - self.assertEqual(test_model.identity, identity) - self.assertEqual(test_model.comment, comment) - test_model = TestModel.objects.get(identity=identity) - self.assertEqual(test_model.firstname, firstname) - self.assertEqual(test_model.identity, identity) - self.assertEqual(test_model.comment, comment) + TestModel.objects.create(**data) + for attr, value in data.items(): + with self.subTest(attr=attr, value=value): + test_model = TestModel.objects.get(**{attr: value}) + for attr1, value1 in data.items(): + self.assertEqual(getattr(test_model, attr1), value1) + self.assertEqual(get_crypt_model_cls().objects.all().count(), 3) + + @tag("6") + def test_model_with_encrypted_fields_empty_string(self): + """Asserts roundtrip via a model with encrypted fields. + + Note: comment is an empty string + """ + data = dict(firstname=None, identity="123456789", comment="") + TestModel.objects.create(**data) + for attr, value in data.items(): + with self.subTest(attr=attr, value=value): + test_model = TestModel.objects.get(**{attr: value}) + for attr1, value1 in data.items(): + self.assertEqual(getattr(test_model, attr1), value1) + self.assertEqual(get_crypt_model_cls().objects.all().count(), 1) def test_model_with_encrypted_fields_as_none(self): - """Asserts roundtrip via a model with encrypted fields.""" + """Asserts roundtrip via a model with encrypted fields. + + Note: comment is None + """ firstname = "erik" identity = "123456789" comment = None @@ -237,6 +293,7 @@ def test_model_with_encrypted_fields_as_none(self): self.assertEqual(test_model.firstname, firstname) self.assertEqual(test_model.identity, identity) self.assertEqual(test_model.comment, comment) + self.assertEqual(get_crypt_model_cls().objects.all().count(), 2) def test_model_with_unique_field(self): """Asserts unique constraint works on an encrypted field. diff --git a/django_crypto_fields/utils.py b/django_crypto_fields/utils.py index 3c8f4e8..9ebf8dd 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -9,7 +9,7 @@ from django.conf import settings from .constants import CIPHER_PREFIX, ENCODING, HASH_ALGORITHM, HASH_PREFIX, HASH_ROUNDS -from .exceptions import MalformedCiphertextError +from .exceptions import EncryptionError, MalformedCiphertextError if TYPE_CHECKING: from django.db import models @@ -110,3 +110,46 @@ def make_hash(value, salt_key) -> bytes: encoded_value = safe_encode_utf8(value) dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, encoded_value, salt_key, HASH_ROUNDS) return binascii.hexlify(dk) + + +def remove_padding(encoded_value: bytes) -> bytes: + """Return original bytes value without padding. + + value: a decrypted bytes value with padding + + Length of padding is stored in last two characters of + value. + """ + try: + padding_length = int(binascii.b2a_hex(encoded_value[-1:])) + except ValueError: + pass + else: + if not padding_length: + encoded_value = encoded_value[:-1] + else: + encoded_value = encoded_value[:-padding_length] + return encoded_value + + +def append_padding(encoded_value: bytes, block_size: int) -> bytes: + """Return an encoded string padded so length is a multiple of + the block size. + + * store length of padding as the last hex value. + * if padding is 0, pad as if padding is 16. + """ + padding_length = (block_size - len(encoded_value) % block_size) % block_size + padding_length = padding_length or 16 + encoded_value = ( + encoded_value + + (b"\x00" * (padding_length - 1)) + + binascii.a2b_hex(str(padding_length).zfill(2)) + ) + if len(encoded_value) % block_size > 0: + multiple = len(encoded_value) / block_size + raise EncryptionError( + f"Padding error, got padded string not a multiple " + f"of {block_size}. Got {multiple}" + ) + return encoded_value From 2eae71df8b0fa67ee62baf57ba3963fc8215a34c Mon Sep 17 00:00:00 2001 From: erikvw Date: Wed, 20 Mar 2024 23:39:58 -0500 Subject: [PATCH 24/25] CHANGES, README --- CHANGES | 8 ++++++++ README.rst | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 63c171f..f0bf637 100644 --- a/CHANGES +++ b/CHANGES @@ -17,6 +17,14 @@ CHANGES (settings.AUTO_CREATE_KEYS will still work) - use pathlib instead of os - remove system checks, instead raise exceptions when Keys is instantiated. +- correctly decode hash_value before storing in DB +- add migration to remove "b'" from hash_values stored in the DB. + You need to run the migration! The migration fixes previously + saved `hash_values` by removing the `b'` prefix and the `'` at the + end. This only applies to `hash_values` in the `Crypt` model. +- use Django cache to store hash/secret pairs in runtime, prefix + cache keys with `django-crypto-fields`. +- add typing hints, reduce complexity. 0.3.10 ------ diff --git a/README.rst b/README.rst index 780b8b8..c9ff1aa 100644 --- a/README.rst +++ b/README.rst @@ -3,12 +3,16 @@ django-crypto-fields -------------------- -version <= 0.3.8: +version < 0.3.8: Python 3.8, 3.9, 3.10 Django 3.2, 4.0, 4.1 using mysql -version >= 0.3.8: +version >= 0.3.8 < 0.4.0 Python 3.11+ Django 4.2+ using mysql +version 0.4.0+ + Python 3.11+ Django 4.2+ using mysql, cache framework + + * Uses ``pycryptodomex`` * This module has known problems with `postgres`. (I hope to address this soon) From 266b04a8ca03832c36c6d9b574d32d2cbf4858f5 Mon Sep 17 00:00:00 2001 From: erikvw Date: Wed, 20 Mar 2024 23:44:04 -0500 Subject: [PATCH 25/25] use methods to build formatted cipher --- django_crypto_fields/cipher/cipher.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/django_crypto_fields/cipher/cipher.py b/django_crypto_fields/cipher/cipher.py index 7bfd56a..bc9478b 100644 --- a/django_crypto_fields/cipher/cipher.py +++ b/django_crypto_fields/cipher/cipher.py @@ -39,10 +39,12 @@ def __init__( @property def cipher(self) -> bytes: - return self.hash_prefix + self.hashed_value + self.cipher_prefix + self.secret + return self.hash_with_prefix + self.secret_with_prefix + @property def hash_with_prefix(self) -> bytes: return self.hash_prefix + self.hashed_value + @property def secret_with_prefix(self) -> bytes: return self.cipher_prefix + self.secret