Skip to content

Commit

Permalink
add support for postgresql, add field classes for date, datetime, int…
Browse files Browse the repository at this point in the history
…, decimal
  • Loading branch information
erikvw committed Aug 6, 2024
1 parent abf6359 commit caa1a60
Show file tree
Hide file tree
Showing 23 changed files with 582 additions and 309 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ _version.py
.etc/
.env/
django_crypto_fields/tests/etc/django_crypto_fields
django_crypto_fields/tests/crypto_keys/django_crypto_fields
.pypirc
.settings
.project
Expand Down
14 changes: 14 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
CHANGES

unreleased
----------
- add support for postgreSQL
- add field classes for additional datatypes:
EncryptedIntegerField, EncryptedDecimalField, EncryptedDateField
EncryptedDateTimeField
- empty strings are now encrypted. Only None values are ignored.
- refactor signatures and typing between encrypt and decrypt

0.4.1
-----
- CACHE_CRYPTO_KEY_PREFIX, settings attribute to customize the
cache prefix.

0.4.0
-----
- merge functionality of key_creator and key_files into keys module,
Expand Down
5 changes: 4 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ version >= 0.3.8 < 0.4.0
version 0.4.0+
Python 3.11+ Django 4.2+ using mysql, cache framework

version 0.4.2+
Python 3.11+ Django 4.2+ mysql or postgres, cache framework


* Uses ``pycryptodomex``
* 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.

Expand Down Expand Up @@ -68,6 +70,7 @@ Add KEY_PREFIX (optional, the default is "user"):
# optional filename prefix for encryption keys files:
KEY_PREFIX = 'bhp066'
Run ``migrate`` to create the ``django_crypto_fields.crypt`` table:

.. code-block:: python
Expand Down
2 changes: 2 additions & 0 deletions django_crypto_fields/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .crypt_model_admin import CryptModelAdmin
from .formfield_overrides import formfield_overrides
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib import admin

from .admin_site import encryption_admin
from .utils import get_crypt_model_cls
from ..admin_site import encryption_admin
from ..utils import get_crypt_model_cls


@admin.register(get_crypt_model_cls(), site=encryption_admin)
Expand Down
21 changes: 21 additions & 0 deletions django_crypto_fields/admin/formfield_overrides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS
from django.db import models

from django_crypto_fields.fields import (
EncryptedCharField,
EncryptedDateField,
EncryptedDateTimeField,
EncryptedIntegerField,
)

FORMFIELD_FOR_DBFIELD_DEFAULTS.update(
{
EncryptedCharField: FORMFIELD_FOR_DBFIELD_DEFAULTS[models.CharField],
EncryptedDateField: FORMFIELD_FOR_DBFIELD_DEFAULTS[models.DateField],
EncryptedDateTimeField: FORMFIELD_FOR_DBFIELD_DEFAULTS[models.DateTimeField],
EncryptedIntegerField: FORMFIELD_FOR_DBFIELD_DEFAULTS[models.IntegerField],
}
)


formfield_overrides = FORMFIELD_FOR_DBFIELD_DEFAULTS
12 changes: 6 additions & 6 deletions django_crypto_fields/cipher/cipher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Callable

from ..constants import CIPHER_PREFIX, HASH_PREFIX
from ..utils import make_hash, safe_encode_utf8
from ..utils import make_hash

__all__ = ["Cipher"]

Expand All @@ -25,17 +25,17 @@ def __init__(
salt_key: bytes,
encrypt: Callable[[bytes], bytes] | None = None,
):
encoded_value = safe_encode_utf8(value)
# encoded_value = safe_encode(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)
self.hash_prefix: bytes = HASH_PREFIX.encode()
self.hashed_value: bytes = make_hash(value, salt_key)
if encrypt:
self.secret = encrypt(encoded_value)
self.cipher_prefix: bytes = safe_encode_utf8(CIPHER_PREFIX)
self.secret = encrypt(value)
self.cipher_prefix: bytes = CIPHER_PREFIX.encode()

@property
def cipher(self) -> bytes:
Expand Down
10 changes: 5 additions & 5 deletions django_crypto_fields/cipher/cipher_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from ..constants import CIPHER_PREFIX, HASH_PREFIX
from ..exceptions import MalformedCiphertextError
from ..utils import make_hash, safe_encode_utf8
from ..utils import make_hash

__all__ = ["CipherParser"]

Expand All @@ -13,22 +13,22 @@ def __init__(self, cipher: bytes, salt_key: bytes | None = None):
self._hash_prefix = None
self._hashed_value = None
self._secret = None
self.cipher = safe_encode_utf8(cipher)
self.cipher = 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)
hash_prefix = HASH_PREFIX.encode()
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)
cipher_prefix = CIPHER_PREFIX.encode()
self._cipher_prefix = cipher_prefix if cipher_prefix in self.cipher else None
return self._cipher_prefix

Expand All @@ -42,7 +42,7 @@ def hashed_value(self) -> bytes | None:

@property
def secret(self) -> bytes | None:
if self.cipher and safe_encode_utf8(CIPHER_PREFIX) in self.cipher:
if self.cipher and CIPHER_PREFIX.encode() in self.cipher:
self._secret = self.cipher.split(self.cipher_prefix)[1]
return self._secret

Expand Down
1 change: 0 additions & 1 deletion django_crypto_fields/constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
AES = "aes"
CIPHER_BUFFER_SIZE = 10
CIPHER_PREFIX = "enc2:::"
ENCODING = "utf-8"
HASH_ALGORITHM = "sha256"
HASH_PREFIX = "enc1:::"
HASH_ROUNDS = 100000
Expand Down
20 changes: 7 additions & 13 deletions django_crypto_fields/cryptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
from Cryptodome import Random
from Cryptodome.Cipher import AES as AES_CIPHER

from .constants import AES, ENCODING, PRIVATE, PUBLIC, RSA
from .constants import AES, PRIVATE, PUBLIC, RSA
from .encoding import safe_encode
from .exceptions import EncryptionError
from .keys import encryption_keys
from .utils import (
append_padding,
get_keypath_from_settings,
remove_padding,
safe_encode_utf8,
)
from .utils import append_padding, get_keypath_from_settings, remove_padding

if TYPE_CHECKING:
from Cryptodome.Cipher._mode_cbc import CbcMode
Expand Down Expand Up @@ -43,7 +39,7 @@ def __init__(self, algorithm, access_mode) -> None:
self.decrypt = getattr(self, f"_{self.algorithm.lower()}_decrypt")

def _aes_encrypt(self, value: str | bytes) -> bytes:
encoded_value = safe_encode_utf8(value)
encoded_value = safe_encode(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)
Expand All @@ -55,13 +51,11 @@ def _aes_decrypt(self, secret: bytes) -> str:
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
return encoded_value.decode() if encoded_value is not None else None

def _rsa_encrypt(self, value: str | bytes) -> bytes:
encoded_value = safe_encode_utf8(value)
try:
secret = self.rsa_public_key.encrypt(encoded_value)
secret = self.rsa_public_key.encrypt(safe_encode(value))
except (ValueError, TypeError) as e:
raise EncryptionError(f"RSA encryption failed for value. Got '{e}'")
return secret
Expand All @@ -73,4 +67,4 @@ def _rsa_decrypt(self, secret: bytes) -> str:
raise EncryptionError(
f"{e} Using RSA from key_path=`{get_keypath_from_settings()}`."
)
return encoded_value.decode(ENCODING)
return encoded_value.decode() if encoded_value is not None else None
68 changes: 68 additions & 0 deletions django_crypto_fields/encoding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Any

from django_crypto_fields.exceptions import (
DjangoCryptoFieldsDecodingError,
DjangoCryptoFieldsEncodingError,
)

ENCODING = "utf-8"
DATETIME_STRING = "%Y-%m-%d %H:%M:%S %z"
DATE_STRING = "%Y-%m-%d"


def safe_encode(value: str | int | Decimal | float | date | datetime | bytes) -> bytes | None:
if value is None:
return None
if type(value) in [str, int, Decimal, float]:
value = str(value).encode()
elif type(value) in [date, datetime]:
value = safe_encode_date(value)
else:
raise DjangoCryptoFieldsEncodingError(
f"Value must be of type str, date or number. Got {value} is {type(value)}"
)
return value


def decode_to_type(value: bytes, to_type: type) -> Any:
if to_type in [date, datetime]:
value = safe_decode_date(value)
elif to_type in [Decimal]:
value = Decimal(value.decode())
elif to_type in [int, float]:
value = to_type(value.decode())
elif to_type in [str]:
value = value.decode()
else:
raise DjangoCryptoFieldsDecodingError(f"Unhandled type. Got {to_type}.")
return value


def safe_decode_date(value: bytes) -> [date, datetime]:
"""Convert bytes to string and confirm date/datetime format"""
value = value.decode()
try:
value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S %z")
except ValueError:
try:
value = datetime.strptime(value, "%Y-%m-%d")
except ValueError:
raise DjangoCryptoFieldsDecodingError(
f"Decoded string value must be in ISO date or datetime format. Got {value}"
)
return value


def safe_encode_date(value: [date, datetime]) -> bytes:
"""Convert date to string and encode."""
if type(value) is datetime:
value = datetime.strftime(value, DATETIME_STRING)
elif type(value) is date:
value = datetime.strftime(value, DATE_STRING)
else:
raise DjangoCryptoFieldsEncodingError(
f"Value must be either a date or datetime. Got {value}."
)
return value.encode()
8 changes: 8 additions & 0 deletions django_crypto_fields/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ class DjangoCryptoFieldsKeyPathDoesNotExist(Exception):
pass


class DjangoCryptoFieldsEncodingError(Exception):
pass


class DjangoCryptoFieldsDecodingError(Exception):
pass


class EncryptionError(Exception):
pass

Expand Down
Loading

0 comments on commit caa1a60

Please sign in to comment.