diff --git a/CHANGES b/CHANGES index c7460b6..f0bf637 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,31 @@ 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. +- 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'. +- 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 +- 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 ------ - update testing matrix to include DJ50. Drop DJ41. diff --git a/README.rst b/README.rst index b081dac..c9ff1aa 100644 --- a/README.rst +++ b/README.rst @@ -3,10 +3,18 @@ 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 < 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`. +* 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 +53,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 +80,18 @@ 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``. + +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 ======= @@ -122,7 +141,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 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..1f382d4 100644 --- a/django_crypto_fields/apps.py +++ b/django_crypto_fields/apps.py @@ -1,113 +1,33 @@ 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 - - -class DjangoCryptoFieldsError(Exception): - pass - - -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 + def import_models(self): + from .keys import encryption_keys # noqa - 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") + return super().import_models() 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/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..bc9478b --- /dev/null +++ b/django_crypto_fields/cipher/cipher.py @@ -0,0 +1,50 @@ +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. + . + For example: + enc1:::234234ed234a24enc2::\x0e\xb9\xae\x13s\x8d + \xe7O\xbb\r\x99. + + 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_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 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 f55d58c..d7ffeb9 100644 --- a/django_crypto_fields/cryptor.py +++ b/django_crypto_fields/cryptor.py @@ -1,16 +1,28 @@ -import binascii +from __future__ import annotations + +from typing import TYPE_CHECKING 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 ( + 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(object): + +class Cryptor: """Base class for all classes providing RSA and AES encryption methods. @@ -18,93 +30,47 @@ class Cryptor(object): of this except the filenames are replaced with the actual keys. """ - def __init__(self, keys=None, 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 - try: - # ignore "keys" parameter if Django is loaded - self.keys = django_apps.get_app_config("django_crypto_fields").encryption_keys - except AppRegistryNotReady: - self.keys = keys + 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"]) + 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 padded(self, plaintext, 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. - * 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 _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) + encoded_value = append_padding(encoded_value, cipher.block_size) + secret = iv + cipher.encrypt(encoded_value) + return secret - def unpadded(self, plaintext, block_size): - """Return original plaintext without padding. + 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) + encoded_value = cipher.decrypt(secret)[AES_CIPHER.block_size :] + encoded_value = remove_padding(encoded_value) + value = encoded_value.decode() + return value - 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, 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) - return iv + cipher.encrypt(padded_plaintext) - - def aes_decrypt(self, ciphertext, mode): - aes_key = "_".join([AES, mode, PRIVATE, "key"]) - iv = ciphertext[: AES_CIPHER.block_size] - cipher = AES_CIPHER.new(getattr(self.keys, aes_key), self.aes_encryption_mode, iv) - plaintext = cipher.decrypt(ciphertext)[AES_CIPHER.block_size :] - return self.unpadded(plaintext, cipher.block_size).decode() - - def rsa_encrypt(self, plaintext, mode): - rsa_key = "_".join([RSA, mode, PUBLIC, "key"]) + def _rsa_encrypt(self, value: str | bytes) -> bytes: + encoded_value = safe_encode_utf8(value) try: - plaintext = plaintext.encode(ENCODING) - except AttributeError: - pass - try: - ciphertext = getattr(self.keys, rsa_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, mode): - rsa_key = "_".join([RSA, mode, PRIVATE, "key"]) + def _rsa_decrypt(self, secret: bytes) -> str: try: - plaintext = getattr(self.keys, rsa_key).decrypt(ciphertext) + encoded_value = self.rsa_private_key.decrypt(secret) except ValueError as e: - raise EncryptionError(f"{e} Using {rsa_key} from key_path=`{settings.KEY_PATH}`.") - return plaintext.decode(ENCODING) + raise EncryptionError( + f"{e} Using RSA from key_path=`{get_keypath_from_settings()}`." + ) + return encoded_value.decode(ENCODING) diff --git a/django_crypto_fields/exceptions.py b/django_crypto_fields/exceptions.py index 09d11e4..6c78f04 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,19 +10,35 @@ class DjangoCryptoFieldsKeysAlreadyLoaded(Exception): pass -class EncryptionError(Exception): +class DjangoCryptoFieldsKeysNotLoaded(Exception): pass -class CipherError(Exception): +class DjangoCryptoFieldsError(Exception): + pass + + +class DjangoCryptoFieldsKeysDoNotExist(Exception): + pass + + +class DjangoCryptoFieldsKeyPathError(Exception): pass -class AlgorithmError(Exception): +class DjangoCryptoFieldsKeyPathChangeError(Exception): pass -class ModeError(Exception): +class DjangoCryptoFieldsKeyPathDoesNotExist(Exception): + pass + + +class EncryptionError(Exception): + pass + + +class CipherError(Exception): pass @@ -32,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 c10a7c7..8b91857 100644 --- a/django_crypto_fields/field_cryptor.py +++ b/django_crypto_fields/field_cryptor.py @@ -1,33 +1,31 @@ -import binascii -import hashlib +from __future__ import annotations 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.cache import cache +from django.core.exceptions import ObjectDoesNotExist -from . import get_crypt_model +from .cipher import Cipher from .constants import ( AES, CIPHER_PREFIX, ENCODING, - HASH_ALGORITHM, HASH_PREFIX, - HASH_ROUNDS, + LOCAL_MODE, PRIVATE, + RESTRICTED_MODE, RSA, SALT, ) from .cryptor import Cryptor -from .exceptions import ( - CipherError, - EncryptionError, - EncryptionKeyError, - MalformedCiphertextError, -) +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 + +__all__ = ["FieldCryptor"] -class FieldCryptor(object): +class FieldCryptor: """Base class for django field classes with encryption. ciphertext = hash_prefix + hashed_value + cipher_prefix + secret @@ -39,142 +37,99 @@ class FieldCryptor(object): 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, mode, keys=None, aes_encryption_mode=None): + def __init__(self, algorithm: str, access_mode: str): self._using = None + self._algorithm = None + self._access_mode = None self.algorithm = algorithm - self.mode = mode - self.aes_encryption_mode = aes_encryption_mode - self.cipher_buffer_key = f"{self.algorithm}_{self.mode}" + self.access_mode = access_mode + self.cipher_buffer_key = b"{self.algorithm}_{self.access_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 - 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.cryptor = Cryptor(aes_encryption_mode=self.aes_encryption_mode) - self.hash_size = len(self.hash("Foo")) + self.keys = encryption_keys + self.cryptor = self.cryptor_cls(algorithm=algorithm, access_mode=access_mode) + self.hash_size: int = len(self.hash("Foo")) - def __repr__(self): - return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.mode}')" + def __repr__(self) -> str: + return f"FieldCryptor(algorithm='{self.algorithm}', mode='{self.access_mode}')" @property - def crypt_model_cls(self): - """Returns the cipher model and avoids issues with model - loading and field classes. - """ - return get_crypt_model() + def algorithm(self) -> str: + return self._algorithm + + @algorithm.setter + 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, plaintext): - """Returns a hexified hash of a plaintext value (as bytes). + @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}." + ) - The hashed value is used as a signature of the "secret". - """ - try: - plaintext = plaintext.encode(ENCODING) - except AttributeError: - pass - attr = "_".join([SALT, self.mode, PRIVATE]) + def hash(self, value) -> bytes: + return make_hash(value, self.salt_key) + + @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}") - dk = hashlib.pbkdf2_hmac(HASH_ALGORITHM, plaintext, salt, HASH_ROUNDS) - return binascii.hexlify(dk) - - def encrypt(self, value, update=None): - """Returns ciphertext as byte data using either an - RSA or AES cipher. - - * 'value' is either plaintext or ciphertext - * 'ciphertext' 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. + return salt + + 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. + + * 'value' may or may not be encoded + * 'update' if True updates the value in the Crypt model + + * `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. """ - 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.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 - ) - ) - return ciphertext + 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( + 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): + def decrypt(self, hash_with_prefix: bytes) -> str | None: """Returns decrypted secret or None. - Secret is retrieved from `Crypt` using the hash. + 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. + hash_with_prefix:bytes = hash_prefix + hash_value. + + See also BaseField.from_db_value. """ - plaintext = None - try: - hash_with_prefix = hash_with_prefix.encode(ENCODING) - 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) - 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 - ) - ) - else: - hashed_value = self.get_hash(hash_with_prefix) - if hashed_value: - raise EncryptionError( - 'Failed to decrypt. Could not find "secret" ' - f" for hash '{hashed_value}'" - ) - else: - raise EncryptionError("Failed to decrypt. Malformed ciphertext") - return plaintext + if secret := self.fetch_secret(hash_with_prefix): + return self.cryptor.decrypt(secret) + return None @property def using(self): @@ -183,222 +138,95 @@ 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 self.verify_ciphertext(ciphertext): - 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.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.mode, - ) - - def verify_ciphertext(self, ciphertext): - """Returns ciphertext after verifying format prefix + - hash + prefix + secret. + @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`. """ - try: - ciphertext.split(HASH_PREFIX.encode(ENCODING))[1] - ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - except IndexError: - ValueError( - f"Malformed ciphertext. Expected prefixes " f"{HASH_PREFIX}, {CIPHER_PREFIX}" + 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 ) - 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): - """Returns the prefix + hash as stored in the DB table column of - your model's "encrypted" field. - - 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 + cache.set(self.cache_key_prefix + cipher.hashed_value, cipher.secret) - def get_hash(self, ciphertext): - """Returns the hashed_value given a ciphertext or None.""" - try: - ciphertext.encode(ENCODING) - except AttributeError: + def get_prep_value(self, value: str | bytes | None) -> str | bytes | None: + """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 field_cls.get_prep_value() + """ + hash_with_prefix = None + encoded_value = safe_encode_utf8(value) + if encoded_value == b"": + encoded_value = "" + elif encoded_value is None: pass - return ciphertext[len(HASH_PREFIX) :][: self.hash_size] or None - - def get_secret(self, ciphertext): - """Returns the secret given a ciphertext.""" - if ciphertext is None: - secret = None - if self.is_encrypted(ciphertext): - secret = ciphertext.split(CIPHER_PREFIX.encode(ENCODING))[1] - return secret + else: + 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 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. - def fetch_secret(self, hash_with_prefix): - hashed_value = self.get_hash(hash_with_prefix) - secret = self.cipher_buffer[self.cipher_buffer_key].get(hashed_value) - if not secret: - try: - cipher = ( - self.crypt_model_cls.objects.using(self.using) - .values("secret") - .get(hash=hashed_value, algorithm=self.algorithm, mode=self.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}'" - ) + 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.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 - def is_encrypted(self, value, has_secret=None): + @staticmethod + def is_encrypted(value: bytes | None) -> bool: """Returns True if value is encrypted. - Value can be: - * a string value - * a well-formed hash - * 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 - 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 - return is_encrypted - - def verify_value(self, value, has_secret=None): - """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): - """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 - def verify_secret(self, ciphertext): - """Verifies secret segment of ciphertext and raises an - exception if not OK. + An encrypted value starts with the hash_prefix. """ - 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 + 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/__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_aes_field.py b/django_crypto_fields/fields/base_aes_field.py index 5a9ecee..2d44b23 100644 --- a/django_crypto_fields/fields/base_aes_field.py +++ b/django_crypto_fields/fields/base_aes_field.py @@ -1,9 +1,11 @@ from ..constants import AES, LOCAL_MODE from .base_field import BaseField +__all__ = ["BaseAesField"] + class BaseAesField(BaseField): def __init__(self, *args, **kwargs): algorithm = AES - mode = LOCAL_MODE - super(BaseAesField, self).__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 7452295..9e00c7f 100644 --- a/django_crypto_fields/fields/base_field.py +++ b/django_crypto_fields/fields/base_field.py @@ -1,41 +1,50 @@ -import sys +from __future__ import annotations + +from typing import TYPE_CHECKING -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 from django.forms import widgets 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 +from ..utils import safe_encode_utf8 + +if TYPE_CHECKING: + from ..keys import Keys -style = color_style() +__all__ = ["BaseField"] 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 + def __init__(self, algorithm: str, access_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.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 = 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 {}. " @@ -46,7 +55,13 @@ 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 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() @@ -65,45 +80,16 @@ def formfield(self, **kwargs): defaults.update(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 = 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("CipherError. Got {}\n".format(str(e)))) - sys.stdout.flush() - # raise ValidationError(e) - except EncryptionError as e: - sys.stdout.write(style.ERROR("EncryptionError. Got {}\n".format(str(e)))) - sys.stdout.flush() - raise - # raise ValidationError(e) - except MalformedCiphertextError as e: - sys.stdout.write(style.ERROR("MalformedCiphertextError. Got {}\n".format(str(e)))) - sys.stdout.flush() - # raise ValidationError(e) - 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 to_python(self, value): - # 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(). """ @@ -115,23 +101,27 @@ 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(BaseField, self).get_prep_lookup(lookup_type, 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 @@ -142,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 c61a536..203e19a 100644 --- a/django_crypto_fields/fields/base_rsa_field.py +++ b/django_crypto_fields/fields/base_rsa_field.py @@ -1,9 +1,11 @@ from ..constants import LOCAL_MODE, RSA from .base_field import BaseField +__all__ = ["BaseRsaField"] + 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_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 96542a4..b7f1c54 100644 --- a/django_crypto_fields/fields/encrypted_decimal_field.py +++ b/django_crypto_fields/fields/encrypted_decimal_field.py @@ -2,11 +2,38 @@ from .base_rsa_field import BaseRsaField +__all__ = ["EncryptedDecimalField"] + 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 +46,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 +61,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(EncryptedDecimalField, self).__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/fields/encrypted_integer_field.py b/django_crypto_fields/fields/encrypted_integer_field.py index def3163..3a5f8bd 100644 --- a/django_crypto_fields/fields/encrypted_integer_field.py +++ b/django_crypto_fields/fields/encrypted_integer_field.py @@ -1,11 +1,13 @@ from .base_rsa_field import BaseRsaField +__all__ = ["EncryptedIntegerField"] + class EncryptedIntegerField(BaseRsaField): description = "local-rsa encrypted field for 'IntegerField'" 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..8b2ede5 100644 --- a/django_crypto_fields/fields/encrypted_text_field.py +++ b/django_crypto_fields/fields/encrypted_text_field.py @@ -2,10 +2,12 @@ from .base_aes_field import BaseAesField +__all__ = ["EncryptedTextField"] + class EncryptedTextField(BaseAesField): description = "Custom field for 'Text' form field, uses local AES" def formfield(self, **kwargs): kwargs["widget"] = widgets.Textarea() - return super(EncryptedTextField, self).formfield(**kwargs) + return super().formfield(**kwargs) diff --git a/django_crypto_fields/fields/firstname_field.py b/django_crypto_fields/fields/firstname_field.py index 8aab55f..fd19bac 100644 --- a/django_crypto_fields/fields/firstname_field.py +++ b/django_crypto_fields/fields/firstname_field.py @@ -1,45 +1,11 @@ -import re - -from django.forms import ValidationError - from .base_rsa_field import BaseRsaField +__all__ = ["FirstnameField"] + class FirstnameField(BaseRsaField): """Restricted-rsa encrypted field for a model's Firstname 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." - ) - # 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:] - ) - ) + pass 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_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..6e1cac7 --- /dev/null +++ b/django_crypto_fields/key_path/__init__.py @@ -0,0 +1,4 @@ +from .key_path import KeyPath +from .persist_key_path_or_raise import persist_key_path_or_raise + +__all__ = ["persist_key_path_or_raise", "KeyPath"] 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..a41c209 --- /dev/null +++ b/django_crypto_fields/key_path/key_path.py @@ -0,0 +1,66 @@ +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: + path = self.create_folder_for_tests_or_raise() + 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) + + @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/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..5ae7db6 --- /dev/null +++ b/django_crypto_fields/key_path/persist_key_path_or_raise.py @@ -0,0 +1,65 @@ +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: + 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: + 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." + ) + ) + + +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 "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(): + style = color_style() + 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/keys.py b/django_crypto_fields/keys.py deleted file mode 100644 index d8401a2..0000000 --- a/django_crypto_fields/keys.py +++ /dev/null @@ -1,106 +0,0 @@ -import copy -import sys - -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 .constants import AES, PRIVATE, RSA, SALT -from .exceptions import DjangoCryptoFieldsKeysAlreadyLoaded -from .key_files import KeyFiles - - -class Keys: - """ - Class to prepare RSA, AES keys for use by field classes. - - * Keys are imported through the AppConfig __init__ method. - * Keys are create through the AppConfig __init__ method, if necessary. - """ - - keys_are_ready = False - rsa_key_info = {} - key_files_cls = KeyFiles - - 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 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." - ) - 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): - """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()) - 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) - return key_file - - def load_aes_key(self, mode): - """Decrypts and loads an AES key into _keys. - - Note: AES does not use a public key. - """ - key = PRIVATE - rsa_key = self._keys[RSA][mode][key] - try: - key_file = self.key_filenames[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 - setattr(self, AES + "_" + mode + "_" + key + "_key", aes_key) - return key_file - - def load_salt_key(self, mode, key): - """Decrypts and loads a salt key into _keys.""" - 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()) - setattr(self, attr, salt) - return key_file - - def update_rsa_key_info(self, rsa_key, mode): - """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) - 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}) 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/keys.py b/django_crypto_fields/keys/keys.py new file mode 100644 index 0000000..d4a2cb1 --- /dev/null +++ b/django_crypto_fields/keys/keys.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import os +from copy import deepcopy +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.core.management.color import color_style + +from ..constants import AES, PRIVATE, PUBLIC, 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 +from .utils import get_filenames, get_template, key_files_exist, write_msg + +style = color_style() + + +class Keys: + """ + Class to prepare RSA, AES keys for use by field classes. + + * Keys are imported through the AppConfig __init__ method. + * Keys are create through the AppConfig __init__ method, if necessary. + """ + + rsa_key_info: dict = {} + key_prefix: str = get_key_prefix_from_settings() + + 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.path = KeyPath().path + self.template = get_template(self.path, self.key_prefix) + self.filenames = get_filenames(self.path, self.key_prefix) + self.initialize() + + def initialize(self): + """Load keys and create if necessary.""" + write_msg(self.verbose, "Loading encryption keys\n") + self.keys = deepcopy(self.template) + persist_key_path_or_raise() + if not key_files_exist(self.path, self.key_prefix): + 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]]) + + def reset(self): + """For use in tests.""" + self.keys = deepcopy(self.template) + self.loaded = False + + 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) + + 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( + f"Not creating new keys. Encryption keys already exist. See {self.path}." + ) + write_msg(self.verbose, style.WARNING(" * Generating new encryption keys ...\n")) + self._create_rsa() + self._create_aes() + self._create_salt() + 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, 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}'." + ) + self.load_rsa_keys() + 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") + 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") + 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") + 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") + + 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[access_mode] = {"bits": mod_bits} + k = number.ceil_div(mod_bits, 8) + self.rsa_key_info[access_mode].update({"bytes": k}) + h_len = rsa_key._hashObj.digest_size + self.rsa_key_info[access_mode].update({"max_message_length": k - (2 * h_len) - 2}) + + def _create_rsa(self) -> None: + """Creates RSA keys.""" + 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(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 {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") + except FileExistsError as e: + raise DjangoCryptoFieldsKeyError(f"RSA key already exists. Got {e}") + + def _create_aes(self) -> None: + """Creates AES keys and RSA encrypts them.""" + 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(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") + + def _create_salt(self) -> None: + """Creates a salt and RSA encrypts it.""" + 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(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") + + +encryption_keys = Keys() diff --git a/django_crypto_fields/keys/utils.py b/django_crypto_fields/keys/utils.py new file mode 100644 index 0000000..a3dfab6 --- /dev/null +++ b/django_crypto_fields/keys/utils.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import sys +from pathlib import Path, PurePath +from typing import Iterator + +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. + + 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 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/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/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/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/models.py b/django_crypto_fields/models.py index 79af93a..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) @@ -44,7 +41,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 deleted file mode 100644 index 3732f80..0000000 --- a/django_crypto_fields/system_checks.py +++ /dev/null @@ -1,86 +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 .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 diff --git a/django_crypto_fields/templatetags/crypto_tags.py b/django_crypto_fields/templatetags/crypto_tags.py index be29b5e..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, has_secret=False): - 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 new file mode 100644 index 0000000..bf0bfe5 --- /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-21 03:44:41.226908+00:00 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 155f0b2..0000000 Binary files a/django_crypto_fields/tests/etc/user-aes-restricted.key and /dev/null differ 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 9753470..0000000 Binary files a/django_crypto_fields/tests/etc/user-salt-restricted.key and /dev/null differ diff --git a/django_crypto_fields/tests/models.py b/django_crypto_fields/tests/models.py index 6f9d8c4..80837ce 100644 --- a/django_crypto_fields/tests/models.py +++ b/django_crypto_fields/tests/models.py @@ -1,12 +1,17 @@ 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 +from django_crypto_fields.fields import ( + EncryptedTextField, + FirstnameField, + IdentityField, + LastnameField, +) +from django_crypto_fields.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_cryptor.py b/django_crypto_fields/tests/test_cryptor.py deleted file mode 100644 index c28595e..0000000 --- a/django_crypto_fields/tests/test_cryptor.py +++ /dev/null @@ -1,91 +0,0 @@ -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 - - -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]) - - def test_encrypt_rsa(self): - """Assert successful RSA roundtrip.""" - cryptor = Cryptor() - plaintext = "erik is a pleeb!!" - for mode in self.keys.rsa_modes_supported: - ciphertext = cryptor.rsa_encrypt(plaintext, mode) - self.assertEqual(plaintext, cryptor.rsa_decrypt(ciphertext, mode)) - - def test_encrypt_aes(self): - """Assert successful AES roundtrip.""" - cryptor = Cryptor() - plaintext = "erik is a pleeb!!" - for mode in self.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"] - plaintext = "".join(["a" for _ in range(0, max_length)]) - cryptor.rsa_encrypt(plaintext, mode) - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext + "a", mode) - - def test_rsa_encoding(self): - """Assert successful RSA roundtrip of byte return str.""" - cryptor = Cryptor() - plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç".encode("utf-8") - ciphertext = cryptor.rsa_encrypt(plaintext, LOCAL_MODE) - t2 = type(cryptor.rsa_decrypt(ciphertext, LOCAL_MODE)) - self.assertTrue(type(t2), "str") - - def test_rsa_type(self): - """Assert fails for anything but str and byte.""" - cryptor = Cryptor() - plaintext = 1 - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext, LOCAL_MODE) - plaintext = 1.0 - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext, LOCAL_MODE) - plaintext = datetime.today() - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, plaintext, LOCAL_MODE) - - def test_no_re_encrypt(self): - """Assert raise error if attempting to encrypt a cipher.""" - cryptor = Cryptor() - plaintext = "erik is a pleeb!!" - ciphertext1 = cryptor.rsa_encrypt(plaintext, LOCAL_MODE) - self.assertRaises(EncryptionError, cryptor.rsa_encrypt, ciphertext1, LOCAL_MODE) - - def test_rsa_roundtrip(self): - cryptor = Cryptor() - plaintext = ( - "erik is a pleeb! ERIK IS A PLEEB 0123456789!@#$%^&*()" "_-+={[}]|\"':;>.<,?/~`±§" - ) - for mode in cryptor.keys.key_filenames.get(RSA): - try: - ciphertext = cryptor.rsa_encrypt(plaintext, mode) - except (AttributeError, TypeError) as e: - self.fail(f"Failed encrypt: {mode} public ({e})\n") - self.assertTrue(plaintext == cryptor.rsa_decrypt(ciphertext, mode)) - - def test_aes_roundtrip(self): - cryptor = Cryptor() - plaintext = ( - "erik is a pleeb!\nERIK IS A PLEEB\n0123456789!@#$%^&*()_" - "-+={[}]|\"':;>.<,?/~`±§\n" - ) - for mode in cryptor.keys.key_filenames[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/test_field_cryptor.py deleted file mode 100644 index dd7c601..0000000 --- a/django_crypto_fields/tests/test_field_cryptor.py +++ /dev/null @@ -1,248 +0,0 @@ -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 - - -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) - value = None - self.assertRaises(TypeError, field_cryptor.verify_hash, value) - value = "" - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) - value = b"" - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) - - def test_can_verify_hash_not_raises(self): - """Assert does NOT raise on valid hash.""" - field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( - "Mohammed Ali floats like a butterfly" - ) - try: - field_cryptor.verify_hash(value) - except MalformedCiphertextError: - self.fail("MalformedCiphertextError unexpectedly raised") - else: - pass - - 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) - value = HASH_PREFIX + "blah" # incorrect prefix - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) - value = HASH_PREFIX # no hash following prefix - self.assertRaises(MalformedCiphertextError, field_cryptor.verify_hash, value) - - 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)) - - 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) - - def test_verify_is_encrypted(self): - field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( - "Mohammed Ali floats like a butterfly" - ) - self.assertTrue(field_cryptor.is_encrypted(value)) - - def test_verify_is_not_encrypted(self): - field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - value = "Mohammed Ali floats like a butterfly" - self.assertFalse(field_cryptor.is_encrypted(value)) - value = b"Mohammed Ali floats like a butterfly" - self.assertFalse(field_cryptor.is_encrypted(value)) - - 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) - value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") - self.assertEqual(value, field_cryptor.verify_value(value)) - - def test_rsa_field_encryption(self): - """Assert successful RSA field roundtrip.""" - plaintext = "erik is a pleeb!!" - for mode in self.keys.key_filenames[RSA]: - field_cryptor = FieldCryptor(RSA, mode) - ciphertext = field_cryptor.encrypt(plaintext) - 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!!∂ƒ˜∫˙ç" - for mode in self.keys.key_filenames[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) - - def test_aes_field_encryption(self): - """Assert successful RSA field roundtrip.""" - plaintext = "erik is a pleeb!!" - for mode in self.keys.key_filenames[AES]: - field_cryptor = FieldCryptor(AES, mode) - ciphertext = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) - - 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]: - field_cryptor = FieldCryptor(RSA, mode) - ciphertext = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) - - 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]: - field_cryptor = FieldCryptor(AES, mode) - ciphertext = field_cryptor.encrypt(plaintext) - self.assertEqual(plaintext, 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!!∂ƒ˜∫˙ç" - for mode in self.keys.key_filenames[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)) - 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!!∂ƒ˜∫˙ç" - cryptor = Cryptor() - field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - 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.rsa_decrypt(secret, LOCAL_MODE)) - - 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) - 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)) - - 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) - self.assertIsNone(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)) - - def test_rsa_field_as_none(self): - """Asserts RSA roundtrip on None.""" - field_cryptor = FieldCryptor(RSA, LOCAL_MODE) - plaintext = None - ciphertext = field_cryptor.encrypt(plaintext) - self.assertIsNone(field_cryptor.decrypt(ciphertext)) - - def test_aes_field_as_none(self): - """Asserts AES roundtrip on None.""" - field_cryptor = FieldCryptor(AES, LOCAL_MODE) - plaintext = None - ciphertext = field_cryptor.encrypt(plaintext) - self.assertIsNone(field_cryptor.decrypt(ciphertext)) - - 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 - ) - 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) - - def test_model_with_encrypted_fields_as_none(self): - """Asserts roundtrip via a model with encrypted fields.""" - firstname = "erik" - identity = "123456789" - comment = None - test_model = TestModel.objects.create( - firstname=firstname, identity=identity, comment=comment - ) - 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) - - def test_model_with_unique_field(self): - """Asserts unique constraint works on an encrypted field. - - identity = EncryptedTextField( - verbose_name="Identity", - unique=True) - """ - firstname = "erik" - identity = "123456789" - comment = "erik is a pleeb!!∂ƒ˜∫˙ç" - TestModel.objects.create(firstname=firstname, identity=identity, comment=comment) - firstname2 = "erik2" - comment2 = "erik was a pleeb!!∂ƒ˜∫˙ç" - with transaction.atomic(): - self.assertRaises( - IntegrityError, - TestModel.objects.create, - firstname=firstname2, - identity=identity, - comment=comment2, - ) - 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) 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..952211e --- /dev/null +++ b/django_crypto_fields/tests/test_settings.py @@ -0,0 +1,29 @@ +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 / "crypto_keys", + 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", + 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/management/__init__.py b/django_crypto_fields/tests/tests/__init__.py similarity index 100% rename from django_crypto_fields/management/__init__.py rename to django_crypto_fields/tests/tests/__init__.py diff --git a/django_crypto_fields/tests/tests/test_cryptor.py b/django_crypto_fields/tests/tests/test_cryptor.py new file mode 100644 index 0000000..80cfce1 --- /dev/null +++ b/django_crypto_fields/tests/tests/test_cryptor.py @@ -0,0 +1,95 @@ +from datetime import datetime + +from django.test import TestCase + +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): + encryption_keys.reset_and_delete_keys(verbose=False) + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + encryption_keys.reset_and_delete_keys(verbose=False) + + def test_mode_support(self): + 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.""" + for mode in encryption_keys.rsa_modes_supported: + 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.""" + for mode in encryption_keys.aes_modes_supported: + 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.""" + 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.encrypt(plaintext) + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext + "a") + + def test_rsa_encoding(self): + """Assert successful RSA roundtrip of byte return str.""" + cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) + plaintext = "erik is a pleeb!!∂ƒ˜∫˙ç".encode("utf-8") + 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(algorithm=RSA, access_mode=LOCAL_MODE) + plaintext = 1 + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext) + plaintext = 1.0 + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext) + plaintext = datetime.today() + self.assertRaises(EncryptionError, cryptor.encrypt, plaintext) + + def test_no_re_encrypt(self): + """Assert raise error if attempting to encrypt a cipher.""" + cryptor = Cryptor(algorithm=RSA, access_mode=LOCAL_MODE) + plaintext = "erik is a pleeb!!" + ciphertext1 = cryptor.encrypt(plaintext) + self.assertRaises(EncryptionError, cryptor.encrypt, ciphertext1) + + def test_rsa_roundtrip(self): + plaintext = ( + "erik is a pleeb! ERIK IS A PLEEB 0123456789!@#$%^&*()" "_-+={[}]|\"':;>.<,?/~`±§" + ) + for mode in encryption_keys.rsa_modes_supported: + cryptor = Cryptor(algorithm=RSA, access_mode=mode) + try: + ciphertext = cryptor.encrypt(plaintext) + except (AttributeError, TypeError) as e: + self.fail(f"Failed encrypt: {mode} public ({e})\n") + self.assertTrue(plaintext == cryptor.decrypt(ciphertext)) + + def test_aes_roundtrip(self): + plaintext = ( + "erik is a pleeb!\nERIK IS A PLEEB\n0123456789!@#$%^&*()_" + "-+={[}]|\"':;>.<,?/~`±§\n" + ) + 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.decrypt(ciphertext)) diff --git a/django_crypto_fields/tests/tests/test_field_cryptor.py b/django_crypto_fields/tests/tests/test_field_cryptor.py new file mode 100644 index 0000000..8814967 --- /dev/null +++ b/django_crypto_fields/tests/tests/test_field_cryptor.py @@ -0,0 +1,322 @@ +from django.db import transaction +from django.db.utils import IntegrityError +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 ( + get_crypt_model_cls, + has_valid_hash_or_raise, + safe_encode_utf8, +) + +from ..models import TestModel + + +class TestFieldCryptor(TestCase): + def setUp(self): + encryption_keys.reset_and_delete_keys(verbose=False) + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + encryption_keys.reset_and_delete_keys(verbose=False) + + def test_can_verify_hash_as_none(self): + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = None + self.assertRaises(TypeError, has_valid_hash_or_raise, value, field_cryptor.hash_size) + value = "" + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) + value = b"" + 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.""" + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( + "Mohammed Ali floats like a butterfly" + ) + try: + has_valid_hash_or_raise(value, field_cryptor.hash_size) + except MalformedCiphertextError: + self.fail("MalformedCiphertextError unexpectedly raised") + else: + pass + + 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, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) + value = HASH_PREFIX + "blah" # incorrect prefix + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) + value = HASH_PREFIX # no hash following prefix + self.assertRaises( + MalformedCiphertextError, has_valid_hash_or_raise, value, field_cryptor.hash_size + ) + + def test_verify_hashed_value(self): + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = field_cryptor.encrypt("Mohammed Ali floats like a butterfly") + p = CipherParser(value, field_cryptor.salt_key) + try: + p.validate_hashed_value() + except MalformedCiphertextError: + self.fail("MalformedCiphertextError unexpectedly raised") + + def test_verify_is_encrypted(self): + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = HASH_PREFIX.encode(ENCODING) + field_cryptor.hash( + "Mohammed Ali floats like a butterfly" + ) + self.assertTrue(field_cryptor.is_encrypted(value)) + + def test_verify_is_not_encrypted(self): + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = "Mohammed Ali floats like a butterfly" + self.assertFalse(field_cryptor.is_encrypted(value)) + value = b"Mohammed Ali floats like a butterfly" + self.assertFalse(field_cryptor.is_encrypted(value)) + + def test_verify_value(self): + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + 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.""" + plaintext = "erik is a pleeb!!" + for mode in encryption_keys.get(RSA): + field_cryptor = FieldCryptor(RSA, mode) + ciphertext = field_cryptor.encrypt(plaintext) + self.assertEqual(plaintext, field_cryptor.decrypt(ciphertext)) + + def test_rsa_field_encryption_update_secret(self): + """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) + 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.""" + 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(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext)) + + def test_rsa_field_encryption_encoded(self): + """Assert successful RSA field roundtrip.""" + 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(encoded_value) + self.assertEqual(encoded_value.decode(), field_cryptor.decrypt(ciphertext)) + + def test_aes_field_encryption_encoded(self): + """Assert successful AES field roundtrip.""" + 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(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.""" + 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(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. + """ + 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(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(value, cryptor.decrypt(secret)) + + def test_aes_update_crypt_model(self): + """Asserts plaintext can be encrypted, saved to model, + retrieved by hash, and decrypted. + """ + value = "erik is a pleeb!!∂ƒ˜∫˙ç" + encoded_value = safe_encode_utf8(value) + field_cryptor = FieldCryptor(AES, LOCAL_MODE) + 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(encoded_value.decode(), field_cryptor.cryptor.decrypt(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) + + 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) + + 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( + 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_empty(self): + """Asserts RSA cannot roundtrip on None.""" + field_cryptor = FieldCryptor(RSA, LOCAL_MODE) + value = b"" + cipher = field_cryptor.encrypt(value) + self.assertIsNone(field_cryptor.decrypt(cipher)) + + def test_aes_field_as_empty(self): + """Asserts AES cannot roundtrip on None.""" + field_cryptor = FieldCryptor(AES, LOCAL_MODE) + 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.""" + data = dict( + firstname="erik", + identity="123456789", + comment="erik is a pleeb!!∂ƒ˜∫˙ç", + ) + 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. + + Note: comment is None + """ + firstname = "erik" + identity = "123456789" + comment = None + test_model = TestModel.objects.create( + firstname=firstname, identity=identity, comment=comment + ) + 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) + 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. + + identity = EncryptedTextField( + verbose_name="Identity", + unique=True) + """ + firstname = "erik" + identity = "123456789" + comment = "erik is a pleeb!!∂ƒ˜∫˙ç" + TestModel.objects.create(firstname=firstname, identity=identity, comment=comment) + firstname2 = "erik2" + comment2 = "erik was a pleeb!!∂ƒ˜∫˙ç" + with transaction.atomic(): + self.assertRaises( + IntegrityError, + TestModel.objects.create, + firstname=firstname2, + identity=identity, + comment=comment2, + ) + 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) 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..67630c3 --- /dev/null +++ b/django_crypto_fields/tests/tests/test_keys.py @@ -0,0 +1,110 @@ +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 + +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.reset_and_delete_keys(verbose=False) + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + 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_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_and_delete_keys(verbose=False) + encryption_keys.verbose = False + encryption_keys.initialize() + for file in encryption_keys.filenames: + 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=None, + ) + 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_new_keys_or_raise) + + @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) + + @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) + + 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"]) + + @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"]) + + @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..3a01d30 100644 --- a/django_crypto_fields/tests/test_models.py +++ b/django_crypto_fields/tests/tests/test_models.py @@ -1,11 +1,22 @@ 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 django_crypto_fields.keys import encryption_keys + +from ..models import TestModel class TestModels(TestCase): + + def setUp(self): + encryption_keys.reset_and_delete_keys(verbose=False) + encryption_keys.verbose = False + encryption_keys.initialize() + + def tearDown(self): + encryption_keys.reset_and_delete_keys(verbose=False) + 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..9ebf8dd 100644 --- a/django_crypto_fields/utils.py +++ b/django_crypto_fields/utils.py @@ -1,21 +1,155 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import binascii +import hashlib +import sys +from typing import TYPE_CHECKING, Type + +from django.apps import apps as django_apps +from django.conf import settings + +from .constants import CIPHER_PREFIX, ENCODING, HASH_ALGORITHM, HASH_PREFIX, HASH_ROUNDS +from .exceptions import EncryptionError, MalformedCiphertextError 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() -> Type[Crypt]: + return django_apps.get_model(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") + + +def safe_encode_utf8(value) -> bytes: + try: + value = value.encode(ENCODING) + except AttributeError: + pass + 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. + """ + ciphertext = safe_encode_utf8(ciphertext) + 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" + ) + return True + + +def make_hash(value, salt_key) -> bytes: + """Returns a hexified hash of a plaintext value (as bytes). + + The hashed value is used as a signature of the "secret". + """ + 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 diff --git a/pyproject.toml b/pyproject.toml index bd47c0e..0b3adbe 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] @@ -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 - dj42: Django>=4.2,<5.0 + pre-commit + 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 commands = - pip install -U pip coverage[toml] + pip install -U pip pip --version pip freeze coverage run -a runtests.py 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) 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 =