├── .github └── workflows │ └── lint-and-test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENCE.txt ├── README.md ├── encrypted_fields ├── __init__.py └── fields.py ├── manage.py ├── package_test ├── __init__.py ├── models.py ├── settings.py └── tests.py ├── pyproject.toml ├── requirements.txt └── setup.py /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Install dependencies 14 | run: | 15 | python -m pip install --upgrade pip 16 | pip install ruff black 17 | 18 | - name: Lint with Ruff 19 | run: | 20 | ruff check . 21 | 22 | - name: Lint with Black 23 | run: | 24 | black --check . 25 | 26 | test: 27 | strategy: 28 | matrix: 29 | python_version: ["3.10", 3.11, 3.12] 30 | django_version: [3.2, 4.0, 4.1.0, 4.2.2, 5.0, 5.1.4] 31 | exclude: 32 | - python_version: 3.12 33 | django_version: 3.2 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | 39 | - name: Set up Python ${{ matrix.python_version }} 40 | uses: actions/setup-python@v3 41 | with: 42 | python-version: ${{ matrix.python_version }} 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install -q Django==${{ matrix.django_version }} 48 | pip install coverage pytest 49 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 50 | 51 | - name: Run tests 52 | run: | 53 | coverage3 run --source='./encrypted_fields' manage.py test 54 | coverage xml 55 | 56 | # - name: "Upload coverage to Artifact" 57 | # uses: actions/upload-artifact@v4 58 | # with: 59 | # name: coverage 60 | # path: coverage.xml 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | build/ 4 | dist/ 5 | .idea 6 | .pypirc 7 | .ruff_cache 8 | .venv 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | # exclude docs and static css 5 | exclude: | 6 | (?x)^( 7 | package_test/.* 8 | )$ 9 | repos: 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: trailing-whitespace # Trims trailing whitespace. 14 | args: [--markdown-linebreak-ext=md] 15 | - id: check-ast # Checks whether the files parse as valid python. 16 | - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems. 17 | - id: check-json # Attempts to load all json files to verify syntax 18 | - id: check-merge-conflict # Check for files that contain merge conflict strings 19 | - id: check-xml # Attempts to load all xml files to verify syntax 20 | - id: check-toml # Attempts to load all toml files to verify syntax 21 | - id: check-yaml # Attempts to load all yaml files to verify syntax 22 | args: [--unsafe] 23 | - id: end-of-file-fixer # Makes sure files end in a newline and only a newline. 24 | - id: check-symlinks # Checks for symlinks which do not point to anything 25 | - id: debug-statements # Check for debugger imports and py37+ breakpoint() calls in python source 26 | - id: check-added-large-files # Prevent giant files from being committed 27 | 28 | - repo: https://github.com/Lucas-C/pre-commit-hooks.git 29 | rev: v1.5.5 30 | hooks: 31 | - id: remove-crlf # Replace CRLF end-lines by LF ones before committing 32 | - id: remove-tabs # Replace tabs by whitespaces before committing 33 | 34 | - repo: https://github.com/pre-commit/pygrep-hooks 35 | rev: v1.10.0 36 | hooks: 37 | - id: python-use-type-annotations 38 | 39 | - repo: https://github.com/pre-commit/mirrors-prettier 40 | rev: v4.0.0-alpha.8 41 | hooks: 42 | - id: prettier # Autoformat yaml and markdown files 43 | types_or: [yaml, markdown] 44 | 45 | - repo: https://github.com/asottile/pyupgrade 46 | rev: v3.19.1 47 | hooks: 48 | - id: pyupgrade 49 | name: pyupgrade 50 | args: [--py312-plus] 51 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 fragment.co.jp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Pypi Package](https://badge.fury.io/py/django-fernet-encrypted-fields.png)](http://badge.fury.io/py/django-fernet-encrypted-fields) 2 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 3 | 4 | ### Django Fernet Encrypted Fields 5 | 6 | This package was created as a successor to [django-encrypted-fields](https://github.com/defrex/django-encrypted-fields). 7 | 8 | #### Getting Started 9 | 10 | ```shell 11 | $ pip install django-fernet-encrypted-fields 12 | ``` 13 | 14 | In your `settings.py`, set random SALT_KEY 15 | 16 | ```python 17 | SALT_KEY = '0123456789abcdefghijklmnopqrstuvwxyz' 18 | ``` 19 | 20 | Then, in `models.py` 21 | 22 | ```python 23 | from encrypted_fields.fields import EncryptedTextField 24 | 25 | class MyModel(models.Model): 26 | text_field = EncryptedTextField() 27 | ``` 28 | 29 | Use your model as normal and your data will be encrypted in the database. 30 | 31 | #### Rotating SALT keys 32 | 33 | You can rotate salt keys by turning the `SALT_KEY` settings.py entry into a list. The first key will be used to encrypt all new data, and decryption of existing values will be attempted with all given keys in order. This is useful for key rotation: place a new key at the head of the list for use with all new or changed data, but existing values encrypted with old keys will still be accessible 34 | 35 | ```python 36 | SALT_KEY = [ 37 | 'zyxwvutsrqponmlkjihgfedcba9876543210', 38 | '0123456789abcdefghijklmnopqrstuvwxyz' 39 | ] 40 | ``` 41 | 42 | #### Rotating SECRET_KEY 43 | 44 | When you would want to rotate your `SECRET_KEY`, set the new value and put your old secret key value to `SECRET_KEY_FALLBACKS` list. That way the existing encrypted fields will still work, but when you re-save the field or create new record, it will be encrypted with the new secret key. (supported in Django >=4.1) 45 | 46 | ```python 47 | SECRET_KEY = "new-key" 48 | SECRET_KEY_FALLBACKS = ["old-key"] 49 | ``` 50 | 51 | If you wish to update the existing encrypted records simply load and re-save the models to use the new key. 52 | 53 | ```python 54 | for obj in MyModel.objects.all(): 55 | obj.save() 56 | ``` 57 | 58 | #### Available Fields 59 | 60 | Currently build in and unit-tested fields. They have the same APIs as their non-encrypted counterparts. 61 | 62 | - `EncryptedCharField` 63 | - `EncryptedTextField` 64 | - `EncryptedDateTimeField` 65 | - `EncryptedIntegerField` 66 | - `EncryptedFloatField` 67 | - `EncryptedEmailField` 68 | - `EncryptedBooleanField` 69 | - `EncryptedJSONField` 70 | 71 | ### Compatible Django Version 72 | 73 | | Compatible Django Version | Specifically tested | 74 | | ------------------------- | ------------------- | 75 | | `3.2` | :heavy_check_mark: | 76 | | `4.0` | :heavy_check_mark: | 77 | | `4.1` | :heavy_check_mark: | 78 | | `4.2` | :heavy_check_mark: | 79 | | `5.0` | :heavy_check_mark: | 80 | | `5.1` | :heavy_check_mark: | 81 | -------------------------------------------------------------------------------- /encrypted_fields/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import * # noqa: F403 2 | -------------------------------------------------------------------------------- /encrypted_fields/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import json 5 | from typing import Any 6 | 7 | from cryptography.fernet import Fernet, InvalidToken, MultiFernet 8 | from cryptography.hazmat.backends import default_backend 9 | from cryptography.hazmat.primitives import hashes 10 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 11 | from django.conf import settings 12 | from django.core.validators import MaxValueValidator, MinValueValidator 13 | from django.db import models 14 | from django.db.backends.base.base import BaseDatabaseWrapper 15 | from django.db.backends.base.operations import BaseDatabaseOperations 16 | from django.db.models.expressions import Expression 17 | from django.utils.functional import cached_property 18 | 19 | _TypeAny = Any 20 | 21 | 22 | class EncryptedFieldMixin: 23 | @cached_property 24 | def keys(self) -> list[bytes]: 25 | keys = [] 26 | salt_keys = ( 27 | settings.SALT_KEY 28 | if isinstance(settings.SALT_KEY, list) 29 | else [settings.SALT_KEY] 30 | ) 31 | secret_keys = [settings.SECRET_KEY] + getattr(settings, "SECRET_KEY_FALLBACKS", list()) 32 | for secret_key in secret_keys: 33 | for salt_key in salt_keys: 34 | salt = bytes(salt_key, "utf-8") 35 | kdf = PBKDF2HMAC( 36 | algorithm=hashes.SHA256(), 37 | length=32, 38 | salt=salt, 39 | iterations=100_000, 40 | backend=default_backend(), 41 | ) 42 | keys.append( 43 | base64.urlsafe_b64encode( 44 | kdf.derive(secret_key.encode("utf-8")) 45 | ) 46 | ) 47 | return keys 48 | 49 | @cached_property 50 | def f(self) -> Fernet | MultiFernet: 51 | if len(self.keys) == 1: 52 | return Fernet(self.keys[0]) 53 | return MultiFernet([Fernet(k) for k in self.keys]) 54 | 55 | def get_internal_type(self) -> str: 56 | """ 57 | To treat everything as text 58 | """ 59 | return "TextField" 60 | 61 | def get_prep_value(self, value: _TypeAny) -> _TypeAny: 62 | value = super().get_prep_value(value) 63 | if value: 64 | if not isinstance(value, str): 65 | value = str(value) 66 | return self.f.encrypt(bytes(value, "utf-8")).decode("utf-8") 67 | return None 68 | 69 | def get_db_prep_value( 70 | self, 71 | value: _TypeAny, 72 | connection: BaseDatabaseWrapper, # noqa: ARG002 73 | prepared: bool = False, # noqa: FBT001, FBT002 74 | ) -> _TypeAny: 75 | if not prepared: 76 | value = self.get_prep_value(value) 77 | return value 78 | 79 | def from_db_value( 80 | self, 81 | value: _TypeAny, 82 | expression: Expression, # noqa: ARG002 83 | connection: BaseDatabaseWrapper, # noqa: ARG002 84 | ) -> _TypeAny: 85 | return self.to_python(value) 86 | 87 | def to_python(self, value: _TypeAny) -> _TypeAny: 88 | if ( 89 | value is None 90 | or not isinstance(value, str) 91 | or hasattr(self, "_already_decrypted") 92 | ): 93 | return value 94 | try: 95 | value = self.f.decrypt(bytes(value, "utf-8")).decode("utf-8") 96 | except InvalidToken: 97 | pass 98 | except UnicodeEncodeError: 99 | pass 100 | return super().to_python(value) 101 | 102 | def clean(self, value: _TypeAny, model_instance: models.Field) -> _TypeAny: 103 | """ 104 | Create and assign a semaphore so that to_python method will not try 105 | to decrypt an already decrypted value during cleaning of a form 106 | """ 107 | self._already_decrypted = True 108 | ret = super().clean(value, model_instance) 109 | del self._already_decrypted 110 | return ret 111 | 112 | 113 | class EncryptedCharField(EncryptedFieldMixin, models.CharField): 114 | pass 115 | 116 | 117 | class EncryptedTextField(EncryptedFieldMixin, models.TextField): 118 | pass 119 | 120 | 121 | class EncryptedDateTimeField(EncryptedFieldMixin, models.DateTimeField): 122 | pass 123 | 124 | 125 | class EncryptedIntegerField(EncryptedFieldMixin, models.IntegerField): 126 | @cached_property 127 | def validators(self) -> list[MinValueValidator | MaxValueValidator]: 128 | # These validators can't be added at field initialization time since 129 | # they're based on values retrieved from `connection`. 130 | validators_ = [*self.default_validators, *self._validators] 131 | internal_type = models.IntegerField().get_internal_type() 132 | min_value, max_value = BaseDatabaseOperations.integer_field_ranges[ 133 | internal_type 134 | ] 135 | if min_value is not None and not any( 136 | ( 137 | isinstance(validator, MinValueValidator) 138 | and ( 139 | validator.limit_value() 140 | if callable(validator.limit_value) 141 | else validator.limit_value 142 | ) 143 | >= min_value 144 | ) 145 | for validator in validators_ 146 | ): 147 | validators_.append(MinValueValidator(min_value)) 148 | if max_value is not None and not any( 149 | ( 150 | isinstance(validator, MaxValueValidator) 151 | and ( 152 | validator.limit_value() 153 | if callable(validator.limit_value) 154 | else validator.limit_value 155 | ) 156 | <= max_value 157 | ) 158 | for validator in validators_ 159 | ): 160 | validators_.append(MaxValueValidator(max_value)) 161 | return validators_ 162 | 163 | 164 | class EncryptedDateField(EncryptedFieldMixin, models.DateField): 165 | pass 166 | 167 | 168 | class EncryptedFloatField(EncryptedFieldMixin, models.FloatField): 169 | pass 170 | 171 | 172 | class EncryptedEmailField(EncryptedFieldMixin, models.EmailField): 173 | pass 174 | 175 | 176 | class EncryptedBooleanField(EncryptedFieldMixin, models.BooleanField): 177 | pass 178 | 179 | 180 | class EncryptedJSONField(EncryptedFieldMixin, models.JSONField): 181 | def _encrypt_values(self, value: _TypeAny) -> _TypeAny: 182 | if isinstance(value, dict): 183 | return {key: self._encrypt_values(data) for key, data in value.items()} 184 | if isinstance(value, list): 185 | return [self._encrypt_values(data) for data in value] 186 | value = str(value) 187 | return self.f.encrypt(bytes(value, "utf-8")).decode("utf-8") 188 | 189 | def _decrypt_values(self, value: _TypeAny) -> _TypeAny: 190 | if value is None: 191 | return value 192 | if isinstance(value, dict): 193 | return {key: self._decrypt_values(data) for key, data in value.items()} 194 | if isinstance(value, list): 195 | return [self._decrypt_values(data) for data in value] 196 | value = str(value) 197 | return self.f.decrypt(bytes(value, "utf-8")).decode("utf-8") 198 | 199 | def get_prep_value(self, value: _TypeAny) -> str: 200 | return json.dumps(self._encrypt_values(value=value), cls=self.encoder) 201 | 202 | def get_internal_type(self) -> str: 203 | return "JSONField" 204 | 205 | def to_python(self, value: _TypeAny) -> _TypeAny: 206 | if ( 207 | value is None 208 | or not isinstance(value, str) 209 | or hasattr(self, "_already_decrypted") 210 | ): 211 | return value 212 | try: 213 | value = self._decrypt_values(value=json.loads(value)) 214 | except InvalidToken: 215 | pass 216 | except UnicodeEncodeError: 217 | pass 218 | return super().to_python(value) 219 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if __name__ == "__main__": 5 | os.environ["DJANGO_SETTINGS_MODULE"] = "package_test.settings" 6 | 7 | from django.core.management import execute_from_command_line 8 | 9 | execute_from_command_line(sys.argv) 10 | -------------------------------------------------------------------------------- /package_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-fernet-encrypted-fields/aa6ebe2cb22d346067ec115f2cdf19283ef57564/package_test/__init__.py -------------------------------------------------------------------------------- /package_test/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from encrypted_fields.fields import ( 4 | EncryptedBooleanField, 5 | EncryptedCharField, 6 | EncryptedDateField, 7 | EncryptedDateTimeField, 8 | EncryptedEmailField, 9 | EncryptedFloatField, 10 | EncryptedIntegerField, 11 | EncryptedJSONField, 12 | EncryptedTextField, 13 | ) 14 | 15 | 16 | class TestModel(models.Model): 17 | char = EncryptedCharField(max_length=255, null=True, blank=True) 18 | text = EncryptedTextField(null=True, blank=True) 19 | datetime = EncryptedDateTimeField(null=True, blank=True) 20 | integer = EncryptedIntegerField(null=True, blank=True) 21 | date = EncryptedDateField(null=True, blank=True) 22 | floating = EncryptedFloatField(null=True, blank=True) 23 | email = EncryptedEmailField(null=True, blank=True) 24 | boolean = EncryptedBooleanField(default=False, null=True) 25 | json = EncryptedJSONField(default=dict, null=True, blank=True) 26 | -------------------------------------------------------------------------------- /package_test/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | "default": { 3 | "ENGINE": "django.db.backends.sqlite3", 4 | "NAME": ":memory:", 5 | }, 6 | } 7 | 8 | SECRET_KEY = "abc" 9 | SALT_KEY = "xyz" 10 | 11 | INSTALLED_APPS = ("encrypted_fields", "package_test") 12 | 13 | MIDDLEWARE_CLASSES = [] 14 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 15 | -------------------------------------------------------------------------------- /package_test/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import pytest 5 | from django.core.exceptions import ValidationError 6 | from django.db import connection 7 | from django.test import TestCase, override_settings 8 | from django.utils import timezone 9 | 10 | from .models import TestModel 11 | 12 | 13 | class FieldTest(TestCase): 14 | def get_db_value(self, field: str, model_id: int) -> None: 15 | cursor = connection.cursor() 16 | cursor.execute( 17 | f"select {field} from package_test_testmodel where id = {model_id};" 18 | ) 19 | return cursor.fetchone()[0] 20 | 21 | def test_char_field_encrypted(self) -> None: 22 | plaintext = "Oh hi, test reader!" 23 | 24 | model = TestModel() 25 | model.char = plaintext 26 | model.full_clean() 27 | model.save() 28 | 29 | ciphertext = self.get_db_value("char", model.id) 30 | 31 | assert plaintext != ciphertext 32 | assert "test" not in ciphertext 33 | 34 | fresh_model = TestModel.objects.get(id=model.id) 35 | assert fresh_model.char == plaintext 36 | 37 | def test_text_field_encrypted(self) -> None: 38 | plaintext = "Oh hi, test reader!" * 10 39 | 40 | model = TestModel() 41 | model.text = plaintext 42 | model.full_clean() 43 | model.save() 44 | 45 | ciphertext = self.get_db_value("text", model.id) 46 | 47 | assert plaintext != ciphertext 48 | assert "test" not in ciphertext 49 | 50 | fresh_model = TestModel.objects.get(id=model.id) 51 | assert fresh_model.text == plaintext 52 | 53 | def test_datetime_field_encrypted(self) -> None: 54 | plaintext = timezone.now() 55 | 56 | model = TestModel() 57 | model.datetime = plaintext 58 | model.full_clean() 59 | model.save() 60 | 61 | ciphertext = self.get_db_value("datetime", model.id) 62 | 63 | # Django's normal date serialization format 64 | assert re.search(r"^\d\d\d\d-\d\d-\d\d", ciphertext) is None 65 | 66 | fresh_model = TestModel.objects.get(id=model.id) 67 | assert fresh_model.datetime == plaintext 68 | 69 | plaintext = "text" 70 | model.datetime = plaintext 71 | model.full_clean() 72 | 73 | with pytest.raises(ValidationError): 74 | model.save() 75 | 76 | def test_integer_field_encrypted(self) -> None: 77 | plaintext = 42 78 | 79 | model = TestModel() 80 | model.integer = plaintext 81 | model.full_clean() 82 | model.save() 83 | 84 | ciphertext = self.get_db_value("integer", model.id) 85 | 86 | assert plaintext != ciphertext 87 | assert plaintext != str(ciphertext) 88 | 89 | fresh_model = TestModel.objects.get(id=model.id) 90 | assert fresh_model.integer == plaintext 91 | 92 | # "IntegerField": (-2147483648, 2147483647) 93 | plaintext = 2147483648 94 | model.integer = plaintext 95 | 96 | with pytest.raises(ValidationError): 97 | model.full_clean() 98 | 99 | plaintext = "text" 100 | model.integer = plaintext 101 | 102 | with pytest.raises(TypeError): 103 | model.full_clean() 104 | 105 | def test_date_field_encrypted(self) -> None: 106 | plaintext = timezone.now().date() 107 | 108 | model = TestModel() 109 | model.date = plaintext 110 | model.full_clean() 111 | model.save() 112 | 113 | ciphertext = self.get_db_value("date", model.id) 114 | fresh_model = TestModel.objects.get(id=model.id) 115 | 116 | assert ciphertext != plaintext.isoformat() 117 | assert fresh_model.date == plaintext 118 | 119 | plaintext = "text" 120 | model.date = plaintext 121 | model.full_clean() 122 | 123 | with pytest.raises(ValidationError): 124 | model.save() 125 | 126 | def test_float_field_encrypted(self) -> None: 127 | plaintext = 42.44 128 | 129 | model = TestModel() 130 | model.floating = plaintext 131 | model.full_clean() 132 | model.save() 133 | 134 | ciphertext = self.get_db_value("floating", model.id) 135 | 136 | assert plaintext != ciphertext 137 | assert plaintext != str(ciphertext) 138 | 139 | fresh_model = TestModel.objects.get(id=model.id) 140 | assert fresh_model.floating == plaintext 141 | 142 | plaintext = "text" 143 | model.floating = plaintext 144 | model.full_clean() 145 | 146 | with pytest.raises(ValueError): 147 | model.save() 148 | 149 | def test_email_field_encrypted(self) -> None: 150 | plaintext = "test@gmail.com" 151 | 152 | model = TestModel() 153 | model.email = plaintext 154 | model.full_clean() 155 | model.save() 156 | 157 | ciphertext = self.get_db_value("email", model.id) 158 | 159 | assert plaintext != ciphertext 160 | assert "aron" not in ciphertext 161 | 162 | fresh_model = TestModel.objects.get(id=model.id) 163 | assert fresh_model.email == plaintext 164 | 165 | plaintext = "text" 166 | model.email = plaintext 167 | 168 | with pytest.raises(ValidationError): 169 | model.full_clean() 170 | 171 | def test_boolean_field_encrypted(self) -> None: 172 | plaintext = True 173 | 174 | model = TestModel() 175 | model.boolean = plaintext 176 | model.full_clean() 177 | model.save() 178 | 179 | ciphertext = self.get_db_value("boolean", model.id) 180 | 181 | assert plaintext != ciphertext 182 | assert ciphertext is not True 183 | assert ciphertext != "True" 184 | assert ciphertext != "true" 185 | assert ciphertext != "1" 186 | assert ciphertext != 1 187 | assert not isinstance(ciphertext, bool) 188 | 189 | fresh_model = TestModel.objects.get(id=model.id) 190 | assert fresh_model.boolean == plaintext 191 | 192 | plaintext = "text" 193 | model.boolean = plaintext 194 | model.full_clean() 195 | 196 | with pytest.raises(ValidationError): 197 | model.save() 198 | 199 | def test_json_field_encrypted(self) -> None: 200 | dict_values = { 201 | "key": "value", 202 | "list": ["nested", {"key": "val"}], 203 | "nested": {"child": "sibling"}, 204 | } 205 | 206 | model = TestModel() 207 | model.json = dict_values 208 | model.full_clean() 209 | model.save() 210 | 211 | ciphertext = json.loads(self.get_db_value("json", model.id)) 212 | 213 | assert dict_values != ciphertext 214 | 215 | fresh_model = TestModel.objects.get(id=model.id) 216 | assert fresh_model.json == dict_values 217 | 218 | def test_json_field_retains_keys(self) -> None: 219 | plain_value = {"key": "value", "another_key": "some value"} 220 | 221 | model = TestModel() 222 | model.json = plain_value 223 | model.full_clean() 224 | model.save() 225 | 226 | ciphertext = json.loads(self.get_db_value("json", model.id)) 227 | 228 | assert plain_value.keys() == ciphertext.keys() 229 | 230 | 231 | class RotatedSaltTestCase(TestCase): 232 | @classmethod 233 | @override_settings(SALT_KEY=["abcdefghijklmnopqrstuvwxyz0123456789"]) 234 | def setUpTestData(cls) -> None: 235 | """Create the initial record using the old salt""" 236 | cls.original = TestModel.objects.create(text="Oh hi test reader") 237 | 238 | @override_settings(SALT_KEY=["newkeyhere", "abcdefghijklmnopqrstuvwxyz0123456789"]) 239 | def test_rotated_salt(self) -> None: 240 | """Change the salt, keep the old one as the last in the list for reading""" 241 | plaintext = "Oh hi test reader" 242 | model = TestModel() 243 | model.text = plaintext 244 | model.save() 245 | 246 | ciphertext = FieldTest.get_db_value(self, "text", model.id) 247 | 248 | assert plaintext != ciphertext 249 | assert "test" not in ciphertext 250 | 251 | fresh_model = TestModel.objects.get(id=model.id) 252 | assert fresh_model.text == plaintext 253 | 254 | old_record = TestModel.objects.get(id=self.original.id) 255 | assert fresh_model.text == old_record.text 256 | 257 | assert ciphertext != FieldTest.get_db_value(self, "text", self.original.pk) 258 | 259 | 260 | class RotatedSecretKeyTestCase(TestCase): 261 | 262 | @staticmethod 263 | def clear_cached_properties(): 264 | # we have to clear the cached properties of EncryptedFieldMixin so we have the right encryption keys 265 | text_field = TestModel._meta.get_field('text') 266 | if hasattr(text_field, 'keys'): 267 | del text_field.keys 268 | if hasattr(text_field, 'f'): 269 | del text_field.f 270 | 271 | @classmethod 272 | @override_settings(SECRET_KEY="oldkey") 273 | def setUpTestData(cls) -> None: 274 | """Create the initial record using the old key""" 275 | cls.clear_cached_properties() 276 | cls.original = TestModel.objects.create(text="Oh hi test reader") 277 | cls.clear_cached_properties() 278 | 279 | def tearDown(self): 280 | self.clear_cached_properties() 281 | 282 | @override_settings(SECRET_KEY="newkey", SECRET_KEY_FALLBACKS=["oldkey"]) 283 | def test_old_and_new_secret_keys(self) -> None: 284 | 285 | plaintext = "Oh hi test reader" 286 | model = TestModel() 287 | model.text = plaintext 288 | model.save() 289 | 290 | fresh_model = TestModel.objects.get(id=model.id) 291 | assert fresh_model.text == plaintext 292 | 293 | old_record = TestModel.objects.get(id=self.original.id) 294 | assert old_record.text == plaintext 295 | 296 | @override_settings(SECRET_KEY="newkey") 297 | def test_cannot_decrypt_old_record_with_new_key(self) -> None: 298 | plaintext = "Oh hi test reader" 299 | model = TestModel() 300 | model.text = plaintext 301 | model.save() 302 | 303 | fresh_model = TestModel.objects.get(id=model.id) 304 | assert fresh_model.text == plaintext 305 | 306 | old_record = TestModel.objects.get(id=self.original.id) 307 | # assert that old record text is still encrypted 308 | assert old_record.text.endswith("=") 309 | # assert that old record cannot be decrypted now 310 | assert old_record.text != plaintext 311 | 312 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | ################## 2 | # ruff 3 | ################## 4 | [tool.ruff] 5 | fix = true 6 | lint.fixable = ["ALL"] 7 | lint.ignore = ["A003", "COM812", "D", "DJ008", "ERA001", "ISC001", "PLC2401", "PLC2403", "PT011", "RUF001", "S101", "S105", "S608", "SIM103", "TC001", "TC002", "TC003", "UP040"] 8 | lint.select = ["ALL"] 9 | lint.unfixable = ["ERA001", "F401"] 10 | include = ["encrypted_fields/*.py", "package_test/*.py"] 11 | target-version = "py312" 12 | 13 | ################## 14 | # mypy 15 | ################## 16 | [tool.mypy] 17 | mypy_path = "$MYPY_CONFIG_FILE_DIR" 18 | packages = ["encrypted_fields"] 19 | python_version = "3.12" 20 | 21 | strict = true 22 | warn_unreachable = true 23 | warn_no_return = true 24 | ignore_missing_imports = true 25 | disallow_untyped_decorators = false 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cffi==1.14.6 2 | cryptography==35.0.0 3 | pycparser==2.20 4 | Django>=3.2 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="django-fernet-encrypted-fields", 5 | description=("This is inspired by django-encrypted-fields."), 6 | long_description=open("README.md").read(), 7 | long_description_content_type="text/markdown", 8 | url="http://github.com/jazzband/django-fernet-encrypted-fields/", 9 | license="MIT", 10 | author="jazzband", 11 | author_email="n.anahara@fragment.co.jp", 12 | packages=["encrypted_fields"], 13 | version="0.3.0", 14 | install_requires=[ 15 | "Django>=3.2", 16 | "cryptography>=35.0.0", 17 | ], 18 | ) 19 | --------------------------------------------------------------------------------