Skip to content

Commit

Permalink
Merge branch 'release/0.4.2' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
erikvw committed Aug 15, 2024
2 parents abf6359 + 28ed3cd commit 2045213
Show file tree
Hide file tree
Showing 25 changed files with 615 additions and 329 deletions.
29 changes: 18 additions & 11 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,16 @@ on: [push, pull_request]

jobs:
build:
name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
name: |
build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}, ${{ matrix.database-engine }})
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12']
django-version: ['4.2', '5.0', 'dev']

exclude:
- python-version: '3.12'
django-version: '4.2'
- python-version: '3.11'
django-version: 'dev'
django-version: ['4.2', '5.0', '5.1', 'dev']
database-engine: ["mysql", "postgres"]
services:
mysql:
image: mysql:latest
Expand All @@ -28,6 +24,17 @@ jobs:
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
postgres:
image: postgres:latest
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

steps:
- name: Install pycups and words dependency
Expand All @@ -36,9 +43,9 @@ jobs:
sudo apt-get -y update
sudo apt-get install libcups2-dev wamerican
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -48,7 +55,7 @@ jobs:
echo "dir=$(pip cache dir)" >>$GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
Expand Down
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
16 changes: 16 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
CHANGES

0.4.2
-----
- 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
- tighten up handling of str/bytes data and typing of signatures for
methods receiving str and/or bytes data.

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
24 changes: 14 additions & 10 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
django-crypto-fields
--------------------

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

+-------------------------+----------------+---------------+----------+-----------+
| Version | Python | Django | DB | Cache |
+=========================+================+===============+==========+===========+
| < 0.3.7 | 3.8, 3.9, 3.10 | 3.2, 4.0, 4.1 | mysql | N/A |
+-------------------------+----------------+---------------+----------+-----------+
| 0.3.8 - 0.3.9 | 3.11+ | 4.2+ | mysql | N/A |
+-------------------------+----------------+---------------+----------+-----------+
| 0.4.0 - 0.4.1 | 3.11+ | 4.2+ | mysql | cache |
| | | | | framework |
+-------------------------+----------------+---------------+----------+-----------+
| 0.4.2+ | 3.11+ | 4.2+ | mysql | cache |
| | | | postgres | 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 +71,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
23 changes: 23 additions & 0 deletions django_crypto_fields/admin/formfield_overrides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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,
EncryptedTextField,
)

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],
EncryptedTextField: FORMFIELD_FOR_DBFIELD_DEFAULTS[models.TextField],
}
)


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 2045213

Please sign in to comment.