├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_cryptography ├── __init__.py ├── conf.py ├── core │ ├── __init__.py │ └── signing.py ├── fields.py ├── py.typed ├── typing.py └── utils │ ├── __init__.py │ └── crypto.py ├── docs ├── Makefile ├── conf.py ├── examples.rst ├── fields.rst ├── index.rst ├── installation.rst ├── make.bat ├── migrating.rst ├── releases.rst └── settings.rst ├── pyproject.toml ├── runtests.py ├── setup.cfg └── tests ├── __init__.py ├── fields ├── __init__.py ├── models.py ├── test_encrypted.py ├── test_migrations_encrypted_default │ ├── 0001_initial.py │ ├── 0002_integerencryptedmodel_field_2.py │ └── __init__.py ├── test_migrations_normal_to_encrypted │ ├── 0001_initial.py │ ├── 0002_rename_fields.py │ ├── 0003_add_encrypted_fields.py │ ├── 0004_migrate_data.py │ ├── 0005_remove_old_fields.py │ └── __init__.py └── test_pickle.py ├── settings.py ├── signing ├── __init__.py └── tests.py └── utils ├── __init__.py └── crypto ├── __init__.py └── tests.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Use 2 spaces for the HTML files 14 | [*.html] 15 | indent_size = 2 16 | 17 | # The JSON files contain newlines inconsistently 18 | [*.json] 19 | indent_size = 2 20 | insert_final_newline = false 21 | 22 | # Use 2 spaces for the reStructuredText files 23 | [*.rst] 24 | indent_size = 2 25 | 26 | # Use 2 spaces for the YAML files 27 | [*.{yml,yaml}] 28 | indent_size = 2 29 | 30 | # Makefiles always use tabs for indentation 31 | [Makefile] 32 | indent_style = tab 33 | 34 | # Batch files use tabs for indentation 35 | [*.bat] 36 | indent_style = tab 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | schedule: 9 | interval: weekly 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | mypy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-python@v5 18 | with: 19 | # Use the lowest version of Python supported 20 | python-version: "3.7" 21 | 22 | - uses: actions/cache@v4 23 | with: 24 | path: ~/.cache/pip 25 | key: setup-python-${{ runner.os }}-mypy-pip-${{ hashFiles('**/setup.cfg') }} 26 | restore-keys: | 27 | setup-python-${{ runner.os }}-mypy-pip 28 | 29 | - name: Install mypy 30 | run: pip install -e '.[mypy]' 31 | 32 | - uses: pr-annotators/mypy-pr-annotator@v1.0.0 33 | 34 | - name: Run mypy 35 | run: mypy django_cryptography/ 36 | 37 | pre-commit: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version: "16" 45 | 46 | - uses: actions/setup-python@v5 47 | with: 48 | python-version: "3.10" 49 | 50 | - uses: pre-commit/action@v3.0.1 51 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - stable/** 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | tests: 14 | name: Python ${{ matrix.python-version }} 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | python-version: 20 | - "3.7" 21 | - "3.8" 22 | - "3.9" 23 | - "3.10" 24 | - "3.11" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - uses: actions/cache@v4 34 | with: 35 | path: ~/.cache/pip 36 | key: setup-python-${{ runner.os }}-python-${{ matrix.python-version }}-pip-${{ hashFiles('**/setup.cfg') }} 37 | restore-keys: | 38 | setup-python-${{ runner.os }}-python-${{ matrix.python-version }}-pip 39 | 40 | - name: Upgrade packaging tools 41 | run: python -m pip install --upgrade pip setuptools virtualenv wheel 42 | 43 | - name: Install dependencies 44 | run: python -m pip install --upgrade coverage[toml] tox 45 | 46 | - name: Run tox targets for ${{ matrix.python-version }} 47 | run: | 48 | tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 49 | coverage xml 50 | 51 | - name: Upload coverage to Codecov 52 | uses: codecov/codecov-action@v4 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 3 | 4 | *.iml 5 | 6 | ## Directory-based project format: 7 | .idea/ 8 | 9 | ## File-based project format: 10 | *.ipr 11 | *.iws 12 | 13 | ### Python template 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | env/ 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *,cover 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-executables-have-shebangs 8 | - id: check-json 9 | - id: check-merge-conflict 10 | - id: check-shebang-scripts-are-executable 11 | - id: check-symlinks 12 | - id: check-toml 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: destroyed-symlinks 16 | - id: end-of-file-fixer 17 | - id: fix-byte-order-marker 18 | - id: fix-encoding-pragma 19 | args: [--remove] 20 | exclude: ^docs/ 21 | - id: mixed-line-ending 22 | - id: trailing-whitespace 23 | - repo: https://github.com/psf/black 24 | rev: 23.3.0 25 | hooks: 26 | - id: black 27 | - repo: https://github.com/charliermarsh/ruff-pre-commit 28 | rev: v0.0.261 29 | hooks: 30 | - id: ruff 31 | - repo: https://github.com/pre-commit/mirrors-prettier 32 | rev: v3.0.0-alpha.6 33 | hooks: 34 | - id: prettier 35 | additional_dependencies: 36 | - prettier@2.8.7 37 | - prettier-plugin-toml@0.3.1 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, George Marshall 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of django-cryptography nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | graft docs 4 | graft tests 5 | global-exclude __pycache__ 6 | global-exclude *.py[cod] 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Cryptography 2 | =================== 3 | 4 | A set of primitives for easily encrypting data in Django, wrapping 5 | the Python Cryptography_ library. Also provided is a drop in 6 | replacement for Django's own cryptographic primitives, using 7 | Cryptography_ as the backend provider. 8 | 9 | Do not forget to read the documentation_. 10 | 11 | .. START HIDDEN 12 | .. image:: https://img.shields.io/github/workflow/status/georgemarshall/django-cryptography/CI/master 13 | :target: https://github.com/georgemarshall/django-cryptography/actions/workflows/main.yml 14 | :alt: GitHub Workflow Status (branch) 15 | .. image:: https://img.shields.io/codecov/c/github/georgemarshall/django-cryptography/master 16 | :target: https://app.codecov.io/gh/georgemarshall/django-cryptography/branch/master 17 | :alt: Codecov branch 18 | .. END HIDDEN 19 | 20 | Cryptography by example 21 | ----------------------- 22 | 23 | Using symmetrical encryption to store sensitive data in the database. 24 | Wrap the desired model field with ``encrypt`` to easily 25 | protect its contents. 26 | 27 | .. code-block:: python 28 | 29 | from django.db import models 30 | 31 | from django_cryptography.fields import encrypt 32 | 33 | 34 | class MyModel(models.Model): 35 | name = models.CharField(max_length=50) 36 | sensitive_data = encrypt(models.CharField(max_length=50)) 37 | 38 | The data will now be automatically encrypted when saved to the 39 | database. ``encrypt`` uses an encryption that allows for 40 | bi-directional data retrieval. 41 | 42 | Requirements 43 | ------------ 44 | 45 | * Python_ (3.7, 3.8, 3.9, 3.10, 3.11) 46 | * Cryptography_ (2.0+) 47 | * Django_ (3.2, 4.1, 4.2) 48 | 49 | Installation 50 | ------------ 51 | 52 | .. code-block:: console 53 | 54 | pip install django-cryptography 55 | 56 | .. _Cryptography: https://cryptography.io/ 57 | .. _Django: https://www.djangoproject.com/ 58 | .. _Python: https://www.python.org/ 59 | .. _documentation: https://django-cryptography.readthedocs.io/en/latest/ 60 | -------------------------------------------------------------------------------- /django_cryptography/__init__.py: -------------------------------------------------------------------------------- 1 | from django.utils.version import get_version 2 | 3 | VERSION = (2, 0, 0, "alpha", 0) 4 | 5 | __version__ = get_version(VERSION) 6 | -------------------------------------------------------------------------------- /django_cryptography/conf.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from appconf import AppConf 4 | from cryptography.hazmat.backends import default_backend 5 | from cryptography.hazmat.primitives import hashes 6 | from cryptography.hazmat.primitives.kdf import pbkdf2 7 | from django.conf import settings 8 | from django.utils.encoding import force_bytes 9 | 10 | 11 | class CryptographyConf(AppConf): 12 | BACKEND = default_backend() 13 | DIGEST = hashes.SHA256() 14 | KEY = None 15 | SALT = "django-cryptography" 16 | 17 | class Meta: 18 | prefix = "cryptography" 19 | proxy = True 20 | 21 | def configure_salt(self, value: Any) -> bytes: 22 | return force_bytes(value) 23 | 24 | def configure(self) -> Dict[str, Any]: 25 | backend = self.configured_data["BACKEND"] 26 | digest = self.configured_data["DIGEST"] 27 | salt = self.configured_data["SALT"] 28 | # Key Derivation Function 29 | kdf = pbkdf2.PBKDF2HMAC( 30 | algorithm=digest, 31 | length=digest.digest_size, 32 | salt=salt, 33 | iterations=30000, 34 | backend=backend, 35 | ) 36 | self.configured_data["KEY"] = kdf.derive( 37 | force_bytes(self.configured_data["KEY"] or settings.SECRET_KEY) 38 | ) 39 | return self.configured_data 40 | -------------------------------------------------------------------------------- /django_cryptography/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/django_cryptography/core/__init__.py -------------------------------------------------------------------------------- /django_cryptography/core/signing.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import datetime 3 | import struct 4 | import time 5 | import zlib 6 | from typing import Any, Optional, Type, Union 7 | 8 | from cryptography.hazmat.primitives.hmac import HMAC 9 | from django.conf import settings 10 | from django.core.signing import ( 11 | BadSignature, 12 | JSONSerializer, 13 | SignatureExpired, 14 | b64_decode, 15 | b64_encode, 16 | get_cookie_signer, 17 | ) 18 | from django.utils.encoding import force_bytes 19 | from django.utils.regex_helper import _lazy_re_compile 20 | 21 | from ..typing import Algorithm, Serializer 22 | from ..utils.crypto import HASHES, InvalidAlgorithm, constant_time_compare, salted_hmac 23 | 24 | try: 25 | from django.core.signing import b62_decode, b62_encode # type: ignore 26 | except ImportError: 27 | from django.utils import baseconv 28 | 29 | # Required for Django 3.2 support 30 | b62_decode, b62_encode = baseconv.base62.decode, baseconv.base62.encode 31 | 32 | __all__ = [ 33 | "BadSignature", 34 | "SignatureExpired", 35 | "b64_encode", 36 | "b64_decode", 37 | "base64_hmac", 38 | "get_cookie_signer", 39 | "JSONSerializer", 40 | "dumps", 41 | "loads", 42 | "Signer", 43 | "TimestampSigner", 44 | "BytesSigner", 45 | "FernetSigner", 46 | ] 47 | 48 | _MAX_CLOCK_SKEW = 60 49 | _SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$") 50 | 51 | 52 | def base64_hmac( 53 | salt: str, 54 | value: Union[bytes, str], 55 | key: Union[bytes, str], 56 | algorithm: Algorithm = "sha1", 57 | ) -> str: 58 | return b64_encode( 59 | salted_hmac(salt, value, key, algorithm=algorithm).finalize() 60 | ).decode() 61 | 62 | 63 | def dumps( 64 | obj: Any, 65 | key: Optional[Union[bytes, str]] = None, 66 | salt: str = "django.core.signing", 67 | serializer: Type[Serializer] = JSONSerializer, 68 | compress: bool = False, 69 | ) -> str: 70 | """ 71 | Return URL-safe, hmac signed base64 compressed JSON string. If key is 72 | None, use settings.SECRET_KEY instead. The hmac algorithm is the default 73 | Signer algorithm. 74 | 75 | If compress is True (not the default), check if compressing using zlib can 76 | save some space. Prepend a '.' to signify compression. This is included 77 | in the signature, to protect against zip bombs. 78 | 79 | Salt can be used to namespace the hash, so that a signed string is 80 | only valid for a given namespace. Leaving this at the default 81 | value or re-using a salt value across different parts of your 82 | application without good cause is a security risk. 83 | 84 | The serializer is expected to return a bytestring. 85 | """ 86 | return TimestampSigner(key, salt=salt).sign_object( 87 | obj, serializer=serializer, compress=compress 88 | ) 89 | 90 | 91 | def loads( 92 | s: str, 93 | key: Optional[Union[bytes, str]] = None, 94 | salt: str = "django.core.signing", 95 | serializer: Type[Serializer] = JSONSerializer, 96 | max_age: Optional[Union[int, datetime.timedelta]] = None, 97 | ) -> Any: 98 | """ 99 | Reverse of dumps(), raise BadSignature if signature fails. 100 | 101 | The serializer is expected to accept a bytestring. 102 | """ 103 | return TimestampSigner(key, salt=salt).unsign_object( 104 | s, serializer=serializer, max_age=max_age 105 | ) 106 | 107 | 108 | class Signer: 109 | def __init__( 110 | self, 111 | key: Optional[Union[bytes, str]] = None, 112 | sep: str = ":", 113 | salt: Optional[str] = None, 114 | algorithm: Optional[Algorithm] = None, 115 | ) -> None: 116 | # Use of native strings in all versions of Python 117 | self.key = key or settings.SECRET_KEY 118 | self.sep = sep 119 | if _SEP_UNSAFE.match(self.sep): 120 | raise ValueError( 121 | "Unsafe Signer separator: %r (cannot be empty or consist of " 122 | "only A-z0-9-_=)" % sep, 123 | ) 124 | self.salt = salt or f"{self.__class__.__module__}.{self.__class__.__name__}" 125 | self.algorithm = algorithm or "sha256" 126 | 127 | def signature(self, value: Union[bytes, str]) -> str: 128 | return base64_hmac( 129 | self.salt + "signer", value, self.key, algorithm=self.algorithm 130 | ) 131 | 132 | def sign(self, value: str) -> str: 133 | return f"{value}{self.sep}{self.signature(value)}" 134 | 135 | def unsign(self, signed_value: str) -> str: 136 | if self.sep not in signed_value: 137 | raise BadSignature('No "%s" found in value' % self.sep) 138 | value, sig = signed_value.rsplit(self.sep, 1) 139 | if constant_time_compare(sig, self.signature(value)): 140 | return value 141 | raise BadSignature('Signature "%s" does not match' % sig) 142 | 143 | def sign_object( 144 | self, 145 | obj: Any, 146 | serializer: Type[Serializer] = JSONSerializer, 147 | compress: bool = False, 148 | ) -> str: 149 | """ 150 | Return URL-safe, hmac signed base64 compressed JSON string. 151 | 152 | If compress is True (not the default), check if compressing using zlib 153 | can save some space. Prepend a '.' to signify compression. This is 154 | included in the signature, to protect against zip bombs. 155 | 156 | The serializer is expected to return a bytestring. 157 | """ 158 | data = serializer().dumps(obj) 159 | # Flag for if it's been compressed or not. 160 | is_compressed = False 161 | 162 | if compress: 163 | # Avoid zlib dependency unless compress is being used. 164 | compressed = zlib.compress(data) 165 | if len(compressed) < (len(data) - 1): 166 | data = compressed 167 | is_compressed = True 168 | base64d = b64_encode(data).decode() 169 | if is_compressed: 170 | base64d = "." + base64d 171 | return self.sign(base64d) 172 | 173 | def unsign_object( 174 | self, 175 | signed_obj: str, 176 | serializer: Type[Serializer] = JSONSerializer, 177 | **kwargs: Any, 178 | ) -> Any: 179 | # Signer.unsign() returns str but base64 and zlib compression operate 180 | # on bytes. 181 | base64d = self.unsign(signed_obj, **kwargs).encode() 182 | decompress = base64d[:1] == b"." 183 | if decompress: 184 | # It's compressed; uncompress it first. 185 | base64d = base64d[1:] 186 | data = b64_decode(base64d) 187 | if decompress: 188 | data = zlib.decompress(data) 189 | return serializer().loads(data) 190 | 191 | 192 | class TimestampSigner(Signer): 193 | def timestamp(self) -> str: 194 | return b62_encode(int(time.time())) 195 | 196 | def sign(self, value: str) -> str: 197 | value = f"{value}{self.sep}{self.timestamp()}" 198 | return super().sign(value) 199 | 200 | def unsign( 201 | self, 202 | value: str, 203 | max_age: Optional[Union[int, float, datetime.timedelta]] = None, 204 | ) -> str: 205 | """ 206 | Retrieve original value and check it wasn't signed more 207 | than max_age seconds ago. 208 | """ 209 | result = super().unsign(value) 210 | value, timestamp = result.rsplit(self.sep, 1) 211 | if max_age is not None: 212 | if isinstance(max_age, datetime.timedelta): 213 | max_age = max_age.total_seconds() 214 | # Check timestamp is not older than max_age 215 | age = time.time() - b62_decode(timestamp) 216 | if age > max_age: 217 | raise SignatureExpired(f"Signature age {age} > {max_age} seconds") 218 | return value 219 | 220 | 221 | class BytesSigner: 222 | def __init__( 223 | self, 224 | key: Optional[Union[bytes, str]] = None, 225 | salt: Optional[str] = None, 226 | algorithm: Optional[Algorithm] = None, 227 | ) -> None: 228 | self.key = key or settings.SECRET_KEY 229 | self.salt = salt or f"{self.__class__.__module__}.{self.__class__.__name__}" 230 | self.algorithm = algorithm or "sha256" 231 | 232 | try: 233 | hasher = HASHES[self.algorithm] 234 | except KeyError as e: 235 | raise InvalidAlgorithm( 236 | "%r is not an algorithm accepted by the cryptography module." 237 | % algorithm 238 | ) from e 239 | 240 | self._digest_size = hasher.digest_size 241 | 242 | def signature(self, value: Union[bytes, str]) -> bytes: 243 | return salted_hmac( 244 | self.salt + "signer", value, self.key, algorithm=self.algorithm 245 | ).finalize() 246 | 247 | def sign(self, value: Union[bytes, str]) -> bytes: 248 | return force_bytes(value) + self.signature(value) 249 | 250 | def unsign(self, signed_value: bytes) -> bytes: 251 | value, sig = ( 252 | signed_value[: -self._digest_size], 253 | signed_value[-self._digest_size :], 254 | ) 255 | if constant_time_compare(sig, self.signature(value)): 256 | return value 257 | raise BadSignature('Signature "%r" does not match' % binascii.b2a_base64(sig)) 258 | 259 | 260 | class FernetSigner: 261 | version = b"\x80" 262 | 263 | def __init__( 264 | self, 265 | key: Optional[Union[bytes, str]] = None, 266 | algorithm: Optional[Algorithm] = None, 267 | ) -> None: 268 | self.key = key or settings.SECRET_KEY 269 | self.algorithm = algorithm or "sha256" 270 | 271 | try: 272 | hasher = HASHES[self.algorithm] 273 | except KeyError as e: 274 | raise InvalidAlgorithm( 275 | "%r is not an algorithm accepted by the cryptography module." 276 | % algorithm 277 | ) from e 278 | 279 | self.hasher = hasher 280 | 281 | def signature(self, value: Union[bytes, str]) -> bytes: 282 | h = HMAC( 283 | force_bytes(self.key), 284 | self.hasher, 285 | backend=settings.CRYPTOGRAPHY_BACKEND, # type: ignore 286 | ) 287 | h.update(force_bytes(value)) 288 | return h.finalize() 289 | 290 | def sign(self, value: Union[bytes, str], current_time: int) -> bytes: 291 | payload = struct.pack(">cQ", self.version, current_time) 292 | payload += force_bytes(value) 293 | return payload + self.signature(payload) 294 | 295 | def unsign( 296 | self, 297 | signed_value: bytes, 298 | max_age: Optional[Union[int, float, datetime.timedelta]] = None, 299 | ) -> bytes: 300 | """ 301 | Retrieve original value and check it wasn't signed more 302 | than max_age seconds ago. 303 | """ 304 | h_size, d_size = struct.calcsize(">cQ"), self.hasher.digest_size 305 | fmt = ">cQ%ds%ds" % (len(signed_value) - h_size - d_size, d_size) 306 | try: 307 | version, timestamp, value, sig = struct.unpack(fmt, signed_value) 308 | except struct.error as err: 309 | raise BadSignature("Signature is not valid") from err 310 | if version != self.version: 311 | raise BadSignature("Signature version not supported") 312 | if max_age is not None: 313 | if isinstance(max_age, datetime.timedelta): 314 | max_age = max_age.total_seconds() 315 | # Check timestamp is not older than max_age 316 | age = abs(time.time() - timestamp) 317 | if age > max_age + _MAX_CLOCK_SKEW: 318 | raise SignatureExpired(f"Signature age {age} > {max_age} seconds") 319 | if constant_time_compare(sig, self.signature(signed_value[:-d_size])): 320 | return value 321 | raise BadSignature('Signature "%r" does not match' % binascii.b2a_base64(sig)) 322 | -------------------------------------------------------------------------------- /django_cryptography/fields.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from base64 import b64decode, b64encode 3 | from typing import ( 4 | Any, 5 | Dict, 6 | List, 7 | Optional, 8 | Sequence, 9 | Tuple, 10 | Type, 11 | TypeVar, 12 | Union, 13 | cast, 14 | overload, 15 | ) 16 | 17 | from django.core import checks 18 | from django.core.checks import CheckMessage 19 | from django.db import models 20 | from django.db.backends.base.base import BaseDatabaseWrapper 21 | from django.db.models.lookups import Lookup, Transform 22 | from django.utils.encoding import force_bytes 23 | from django.utils.translation import gettext_lazy as _ 24 | 25 | from django_cryptography.core.signing import SignatureExpired 26 | from django_cryptography.typing import DatabaseWrapper 27 | from django_cryptography.utils.crypto import FernetBytes 28 | 29 | F = TypeVar("F", bound=models.Field) 30 | FIELD_CACHE: Dict[type, type] = {} 31 | 32 | Expired = object() 33 | """Represents an expired encryption value.""" 34 | 35 | 36 | class PickledField(models.BinaryField): 37 | """ 38 | A field for storing pickled objects 39 | """ 40 | 41 | description = _("Pickled data") 42 | empty_values = [None, b""] 43 | supported_lookups = ("exact", "in", "isnull") 44 | 45 | def _dump(self, value: Any) -> bytes: 46 | return pickle.dumps(value) 47 | 48 | def _load(self, value: bytes) -> Any: 49 | return pickle.loads(value) 50 | 51 | def get_lookup(self, lookup_name: str) -> Optional[Type[Lookup]]: 52 | if lookup_name not in self.supported_lookups: 53 | return None 54 | return super().get_lookup(lookup_name) 55 | 56 | def get_transform(self, lookup_name: str) -> Optional[Type[Transform]]: 57 | if lookup_name not in self.supported_lookups: 58 | return None 59 | return super().get_transform(lookup_name) 60 | 61 | def get_db_prep_value( 62 | self, value: Any, connection: BaseDatabaseWrapper, prepared: bool = False 63 | ) -> Optional[bytes]: 64 | if value is not None: 65 | value = self._dump(value) 66 | return super().get_db_prep_value(value, connection, prepared) 67 | 68 | def from_db_value(self, value: Any, *args: Any, **kwargs: Any) -> Any: 69 | if value is not None: 70 | return self._load(force_bytes(value)) 71 | return value 72 | 73 | def value_to_string(self, obj: models.Model) -> str: 74 | """Pickled data is serialized as base64""" 75 | return b64encode(self._dump(self.value_from_object(obj))).decode("ascii") 76 | 77 | def to_python(self, value: Optional[Any]) -> Optional[Any]: 78 | # If it's a string, it should be base64-encoded data 79 | if isinstance(value, str): 80 | return self._load(b64decode(force_bytes(value))) 81 | return value 82 | 83 | 84 | class EncryptedMixin(models.Field): 85 | """ 86 | A field mixin storing encrypted data 87 | 88 | :param bytes key: This is an optional argument. 89 | 90 | Allows for specifying an instance specific encryption key. 91 | :param int ttl: This is an optional argument. 92 | 93 | The amount of time in seconds that a value can be stored for. If the 94 | time to live of the data has passed, it will become unreadable. 95 | The expired value will return an :class:`Expired` object. 96 | """ 97 | 98 | supported_lookups = ("isnull",) 99 | 100 | def __init__(self, *args, **kwargs) -> None: 101 | self.base_class: Type[models.Field] 102 | self.wasinstance: bool 103 | 104 | self.key: Union[bytes, str] = kwargs.pop("key", None) 105 | self.ttl: int = kwargs.pop("ttl", None) 106 | 107 | self._fernet = FernetBytes(self.key) 108 | super().__init__(*args, **kwargs) 109 | 110 | def _description(self) -> str: 111 | return _("Encrypted %s") % super().description 112 | 113 | description = property(_description) # type: ignore[assignment] 114 | 115 | def _dump(self, value: Any) -> bytes: 116 | return self._fernet.encrypt(pickle.dumps(value)) 117 | 118 | def _load(self, value: bytes) -> Any: 119 | try: 120 | return pickle.loads(self._fernet.decrypt(value, self.ttl)) 121 | except SignatureExpired: 122 | return Expired 123 | 124 | def check(self, **kwargs: Any) -> List[CheckMessage]: 125 | errors = super().check(**kwargs) 126 | if getattr(self, "remote_field", None): 127 | errors.append( 128 | checks.Error( 129 | "Base field for encrypted cannot be a related field.", 130 | hint=None, 131 | obj=self, 132 | id="encrypted.E002", 133 | ) 134 | ) 135 | return errors 136 | 137 | def clone(self): 138 | name, path, args, kwargs = super().deconstruct() 139 | # Determine if the class that subclassed us has been subclassed. 140 | if self.__class__.__mro__.index(EncryptedMixin) <= 1: 141 | return encrypt(self.base_class(*args, **kwargs), self.key, self.ttl) 142 | return self.__class__(*args, **kwargs) 143 | 144 | def deconstruct(self) -> Tuple[str, str, Sequence[Any], Dict[str, Any]]: 145 | name, path, args, kwargs = super().deconstruct() 146 | if self.wasinstance is False: 147 | path = f"{self.base_class.__module__}.{self.base_class.__name__}" 148 | # Determine if the class that subclassed us has been subclassed. 149 | elif self.__class__.__mro__.index(EncryptedMixin) <= 1: 150 | path = f"{encrypt.__module__}.{encrypt.__name__}" 151 | args = [self.base_class(*args, **kwargs)] 152 | kwargs = {} 153 | if self.ttl is not None: 154 | kwargs["ttl"] = self.ttl 155 | return name, path, args, kwargs 156 | 157 | def get_lookup(self, lookup_name: str) -> Optional[Any]: 158 | if lookup_name not in self.supported_lookups: 159 | return None 160 | return super().get_lookup(lookup_name) 161 | 162 | def get_transform(self, lookup_name: str) -> Optional[Any]: 163 | if lookup_name not in self.supported_lookups: 164 | return None 165 | return super().get_transform(lookup_name) 166 | 167 | def get_internal_type(self) -> str: 168 | return "BinaryField" 169 | 170 | def get_db_prep_value( 171 | self, value: Any, connection: BaseDatabaseWrapper, prepared: bool = False 172 | ) -> Any: 173 | value = models.Field.get_db_prep_value(self, value, connection, prepared) 174 | if value is not None: 175 | return cast(DatabaseWrapper, connection).Database.Binary(self._dump(value)) 176 | return value 177 | 178 | get_db_prep_save = models.Field.get_db_prep_save 179 | 180 | def from_db_value(self, value, *args, **kwargs) -> Any: 181 | if value is not None: 182 | return self._load(force_bytes(value)) 183 | return value 184 | 185 | 186 | def get_encrypted_field(base_class: Type[F], wasinstance: bool) -> Type[F]: 187 | """ 188 | A get or create method for encrypted fields, we cache the field in 189 | the module to avoid recreation. This also allows us to always return 190 | the same class reference for a field. 191 | """ 192 | assert issubclass(base_class, models.Field) 193 | return FIELD_CACHE.setdefault( 194 | base_class, 195 | type( 196 | ("Encrypted" if wasinstance else "") + base_class.__name__, 197 | (EncryptedMixin, base_class), 198 | {"base_class": base_class, "wasinstance": wasinstance}, 199 | ), 200 | ) 201 | 202 | 203 | @overload 204 | def encrypt(base_field: F, key=None, ttl=None) -> F: 205 | ... 206 | 207 | 208 | @overload 209 | def encrypt(base_field: Type[F]) -> Type[F]: 210 | ... 211 | 212 | 213 | def encrypt( 214 | base_field, key: Optional[Union[bytes, str]] = None, ttl: Optional[int] = None 215 | ): 216 | """ 217 | A decorator for creating encrypted model fields. 218 | 219 | :param base_field: Base Field to encrypt 220 | :param bytes key: This is an optional argument. 221 | 222 | Allows for specifying an instance specific encryption key. 223 | :param int ttl: This is an optional argument. 224 | 225 | The amount of time in seconds that a value can be stored for. If the 226 | time to live of the data has passed, it will become unreadable. 227 | The expired value will return an :class:`Expired` object. 228 | """ 229 | if isinstance(base_field, type): 230 | assert issubclass(base_field, models.Field) 231 | assert key is None 232 | assert ttl is None 233 | return get_encrypted_field(base_field, False) 234 | 235 | name, path, args, kwargs = base_field.deconstruct() 236 | kwargs.update({"key": key, "ttl": ttl}) 237 | return get_encrypted_field(type(base_field), True)(*args, **kwargs) 238 | -------------------------------------------------------------------------------- /django_cryptography/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/django_cryptography/py.typed -------------------------------------------------------------------------------- /django_cryptography/typing.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Optional, Union 3 | 4 | from typing_extensions import Literal, Protocol 5 | 6 | Algorithm = Literal[ 7 | "blake2b", 8 | "blake2s", 9 | "md5", 10 | "sha1", 11 | "sha224", 12 | "sha256", 13 | "sha384", 14 | "sha3_224", 15 | "sha3_256", 16 | "sha3_384", 17 | "sha3_512", 18 | "sha512", 19 | "sha512_224", 20 | "sha512_256", 21 | "sm3", 22 | ] 23 | 24 | 25 | class DBAPI(Protocol): 26 | def Binary(self, obj: Union[bytes, str]) -> Any: 27 | ... 28 | 29 | 30 | class DatabaseWrapper(Protocol): 31 | Database: DBAPI 32 | 33 | 34 | class Serializer(Protocol): 35 | def dumps(self, obj: Any) -> bytes: 36 | ... 37 | 38 | def loads(self, data: bytes) -> Any: 39 | ... 40 | 41 | 42 | class Signer(Protocol): 43 | def __init__( 44 | self, key: Optional[Union[bytes, str]] = None, algorithm: Optional[str] = None 45 | ) -> None: 46 | ... 47 | 48 | def signature(self, value: Union[bytes, str]) -> bytes: 49 | ... 50 | 51 | def sign(self, value: Union[bytes, str], current_time: int) -> bytes: 52 | ... 53 | 54 | def unsign( 55 | self, 56 | signed_value: bytes, 57 | max_age: Optional[Union[int, float, datetime.timedelta]] = None, 58 | ) -> bytes: 59 | ... 60 | -------------------------------------------------------------------------------- /django_cryptography/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/django_cryptography/utils/__init__.py -------------------------------------------------------------------------------- /django_cryptography/utils/crypto.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import time 4 | from binascii import Error 5 | from typing import Dict, Optional, Union 6 | 7 | from cryptography.hazmat.primitives import constant_time, hashes, padding 8 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 9 | from cryptography.hazmat.primitives.hmac import HMAC 10 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 11 | from django.utils import crypto 12 | from django.utils.encoding import force_bytes 13 | 14 | from ..conf import CryptographyConf 15 | from ..typing import Algorithm, Signer 16 | 17 | settings = CryptographyConf() 18 | 19 | 20 | # AddedInDjango30Warning: Remove when Django 2.2 leaves LTS 21 | class InvalidAlgorithm(ValueError): 22 | """Algorithm is not supported by cryptography.""" 23 | 24 | pass 25 | 26 | 27 | class InvalidToken(Exception): 28 | pass 29 | 30 | 31 | HASHES: Dict[Algorithm, hashes.HashAlgorithm] = { 32 | "blake2b": hashes.BLAKE2b(64), 33 | "blake2s": hashes.BLAKE2s(32), 34 | "md5": hashes.MD5(), 35 | "sha1": hashes.SHA1(), 36 | "sha224": hashes.SHA224(), 37 | "sha256": hashes.SHA256(), 38 | "sha384": hashes.SHA384(), 39 | "sha3_224": hashes.SHA3_224(), 40 | "sha3_256": hashes.SHA3_256(), 41 | "sha3_384": hashes.SHA3_384(), 42 | "sha3_512": hashes.SHA3_512(), 43 | "sha512": hashes.SHA512(), 44 | "sha512_224": hashes.SHA512_224(), 45 | "sha512_256": hashes.SHA512_256(), 46 | "sm3": hashes.SM3(), 47 | } 48 | 49 | 50 | def salted_hmac( 51 | key_salt: Union[bytes, str], 52 | value: Union[bytes, str], 53 | secret: Optional[Union[bytes, str]] = None, 54 | *, 55 | algorithm: Algorithm = "sha1", 56 | ) -> HMAC: 57 | """ 58 | Return the HMAC of 'value', using a key generated from key_salt and a 59 | secret (which defaults to settings.SECRET_KEY). Default algorithm is SHA1, 60 | but any algorithm name supported by cryptography can be passed. 61 | 62 | A different key_salt should be passed in for every application of HMAC. 63 | """ 64 | if secret is None: 65 | secret = settings.SECRET_KEY 66 | 67 | key_salt = force_bytes(key_salt) 68 | secret = force_bytes(secret) 69 | try: 70 | hasher = HASHES[algorithm] 71 | except KeyError as e: 72 | raise InvalidAlgorithm( 73 | "%r is not an algorithm accepted by the cryptography module." % algorithm 74 | ) from e 75 | 76 | # We need to generate a derived key from our base key. We can do this by 77 | # passing the key_salt and our base key through a pseudo-random function. 78 | digest = hashes.Hash(hasher, backend=settings.CRYPTOGRAPHY_BACKEND) 79 | digest.update(key_salt + secret) 80 | key = digest.finalize() 81 | 82 | # If len(key_salt + secret) > sha_constructor().block_size, the above 83 | # line is redundant and could be replaced by key = key_salt + secret, since 84 | # the hmac module does the same thing for keys longer than the block size. 85 | # However, we need to ensure that we *always* do this. 86 | h = HMAC(key, hasher, backend=settings.CRYPTOGRAPHY_BACKEND) 87 | h.update(force_bytes(value)) 88 | return h 89 | 90 | 91 | get_random_string = crypto.get_random_string 92 | 93 | 94 | def constant_time_compare(val1: Union[bytes, str], val2: Union[bytes, str]) -> bool: 95 | """Return True if the two strings are equal, False otherwise.""" 96 | return constant_time.bytes_eq(force_bytes(val1), force_bytes(val2)) 97 | 98 | 99 | def pbkdf2( 100 | password: Union[bytes, str], 101 | salt: Union[bytes, str], 102 | iterations: int, 103 | dklen: int = 0, 104 | digest: Optional[hashes.HashAlgorithm] = None, 105 | ) -> bytes: 106 | """ 107 | Implements PBKDF2 with the same API as Django's existing 108 | implementation, using cryptography. 109 | """ 110 | if digest is None: 111 | digest = hashes.SHA256() 112 | dklen = dklen or digest.digest_size 113 | password = force_bytes(password) 114 | salt = force_bytes(salt) 115 | kdf = PBKDF2HMAC( 116 | digest, dklen, salt, iterations, backend=settings.CRYPTOGRAPHY_BACKEND 117 | ) 118 | return kdf.derive(password) 119 | 120 | 121 | class FernetBytes: 122 | """ 123 | This is a modified version of the Fernet encryption algorithm from 124 | the Python Cryptography library. The main change is the allowance 125 | of varied length cryptographic keys from the base 128-bit. There is 126 | also an emphasis on using Django's settings system for sane defaults. 127 | """ 128 | 129 | def __init__( 130 | self, key: Optional[Union[bytes, str]] = None, signer: Optional[Signer] = None 131 | ) -> None: 132 | if signer is None: 133 | from ..core.signing import FernetSigner 134 | 135 | signer = FernetSigner() 136 | self.key = key or settings.CRYPTOGRAPHY_KEY 137 | self.signer = signer 138 | 139 | def encrypt(self, data: Union[bytes, str]) -> bytes: 140 | return self.encrypt_at_time(data, int(time.time())) 141 | 142 | def encrypt_at_time(self, data: Union[bytes, str], current_time: int) -> bytes: 143 | data = force_bytes(data) 144 | iv = os.urandom(16) 145 | return self._encrypt_from_parts(data, current_time, iv) 146 | 147 | def _encrypt_from_parts(self, data: bytes, current_time: int, iv: bytes) -> bytes: 148 | padder = padding.PKCS7(algorithms.AES.block_size).padder() 149 | padded_data = padder.update(data) + padder.finalize() 150 | encryptor = Cipher( 151 | algorithms.AES(force_bytes(self.key)), 152 | modes.CBC(iv), 153 | backend=settings.CRYPTOGRAPHY_BACKEND, 154 | ).encryptor() 155 | ciphertext = encryptor.update(padded_data) + encryptor.finalize() 156 | 157 | return self.signer.sign(iv + ciphertext, current_time) 158 | 159 | def decrypt(self, data: bytes, ttl: Optional[int] = None) -> bytes: 160 | data = self.signer.unsign(data, ttl) 161 | 162 | iv = data[:16] 163 | ciphertext = data[16:] 164 | decryptor = Cipher( 165 | algorithms.AES(force_bytes(self.key)), 166 | modes.CBC(iv), 167 | backend=settings.CRYPTOGRAPHY_BACKEND, 168 | ).decryptor() 169 | plaintext_padded = decryptor.update(ciphertext) 170 | try: 171 | plaintext_padded += decryptor.finalize() 172 | except ValueError as err: 173 | raise InvalidToken from err 174 | 175 | # Remove padding 176 | unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() 177 | unpadded = unpadder.update(plaintext_padded) 178 | try: 179 | unpadded += unpadder.finalize() 180 | except ValueError as err: 181 | raise InvalidToken from err 182 | return unpadded 183 | 184 | 185 | class Fernet(FernetBytes): 186 | def __init__( 187 | self, key: Optional[Union[bytes, str]] = None, signer: Optional[Signer] = None 188 | ) -> None: 189 | if signer is None: 190 | from ..core.signing import FernetSigner 191 | 192 | signer = FernetSigner() 193 | if key is None: 194 | super().__init__() 195 | else: 196 | key = base64.urlsafe_b64decode(key) 197 | if len(key) != 32: 198 | raise ValueError("Fernet key must be 32 url-safe base64-encoded bytes.") 199 | 200 | super().__init__(key[16:], type(signer)(key[:16])) 201 | 202 | def _encrypt_from_parts(self, data: bytes, current_time: int, iv: bytes) -> bytes: 203 | payload = super()._encrypt_from_parts(data, current_time, iv) 204 | return base64.urlsafe_b64encode(payload) 205 | 206 | def decrypt(self, token: bytes, ttl: Optional[int] = None) -> bytes: 207 | try: 208 | data = base64.urlsafe_b64decode(token) 209 | except (TypeError, Error) as err: 210 | raise InvalidToken from err 211 | return super().decrypt(data, ttl) 212 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-cryptography.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-cryptography.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-cryptography" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-cryptography" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # django-cryptography documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Mar 7 23:04:00 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | import os 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath("..")) 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | "sphinx.ext.autodoc", 34 | "sphinx.ext.intersphinx", 35 | "sphinx.ext.viewcode", 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = ".rst" 45 | 46 | # The encoding of source files. 47 | # source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = "index" 51 | 52 | # General information about the project. 53 | project = "Django Cryptography" 54 | copyright = "2016, George Marshall" 55 | author = "George Marshall" 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = "0.1" 63 | # The full version, including alpha/beta/rc tags. 64 | try: 65 | from django_cryptography import VERSION, get_version 66 | except ImportError: 67 | release = version 68 | else: 69 | 70 | def cryptography_release(): 71 | pep440ver = get_version() 72 | if VERSION[3:5] == ("alpha", 0) and "dev" not in pep440ver: 73 | return pep440ver + ".dev" 74 | return pep440ver 75 | 76 | release = cryptography_release() 77 | 78 | # The language for content autogenerated by Sphinx. Refer to documentation 79 | # for a list of supported languages. 80 | # 81 | # This is also used if you do content translation via gettext catalogs. 82 | # Usually you set "language" from the command line for these cases. 83 | language = None 84 | 85 | # There are two options for replacing |today|: either, you set today to some 86 | # non-false value, then it is used: 87 | # today = '' 88 | # Else, today_fmt is used as the format for a strftime call. 89 | # today_fmt = '%B %d, %Y' 90 | 91 | # List of patterns, relative to source directory, that match files and 92 | # directories to ignore when looking for source files. 93 | exclude_patterns = ["_build"] 94 | 95 | # The reST default role (used for this markup: `text`) to use for all 96 | # documents. 97 | # default_role = None 98 | 99 | # If true, '()' will be appended to :func: etc. cross-reference text. 100 | # add_function_parentheses = True 101 | 102 | # If true, the current module name will be prepended to all description 103 | # unit titles (such as .. function::). 104 | # add_module_names = True 105 | 106 | # If true, sectionauthor and moduleauthor directives will be shown in the 107 | # output. They are ignored by default. 108 | # show_authors = False 109 | 110 | # The name of the Pygments (syntax highlighting) style to use. 111 | pygments_style = "sphinx" 112 | 113 | # Links to Python's docs should reference the most recent version of the 3.x 114 | # branch, which is located at this URL. 115 | intersphinx_mapping = { 116 | "python": ("https://docs.python.org/3/", None), 117 | "cryptography": ("https://cryptography.io/en/stable/", None), 118 | "django": ("https://django.readthedocs.org/en/stable/", None), 119 | } 120 | 121 | # Python's docs don't change every week. 122 | intersphinx_cache_limit = 90 # days 123 | 124 | # A list of ignored prefixes for module index sorting. 125 | # modindex_common_prefix = [] 126 | 127 | # If true, keep warnings as "system message" paragraphs in the built documents. 128 | # keep_warnings = False 129 | 130 | # If true, `todo` and `todoList` produce output, else they produce nothing. 131 | todo_include_todos = False 132 | 133 | # -- Options for HTML output ---------------------------------------------- 134 | 135 | # The theme to use for HTML and HTML Help pages. See the documentation for 136 | # a list of builtin themes. 137 | html_theme = "sphinx_rtd_theme" 138 | 139 | # Theme options are theme-specific and customize the look and feel of a theme 140 | # further. For a list of options available for each theme, see the 141 | # documentation. 142 | # html_theme_options = {} 143 | 144 | # Add any paths that contain custom themes here, relative to this directory. 145 | # html_theme_path = [] 146 | 147 | # The name for this set of Sphinx documents. If None, it defaults to 148 | # " v documentation". 149 | # html_title = None 150 | 151 | # A shorter title for the navigation bar. Default is the same as html_title. 152 | # html_short_title = None 153 | 154 | # The name of an image file (relative to this directory) to place at the top 155 | # of the sidebar. 156 | # html_logo = None 157 | 158 | # The name of an image file (relative to this directory) to use as a favicon of 159 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 160 | # pixels large. 161 | # html_favicon = None 162 | 163 | # Add any paths that contain custom static files (such as style sheets) here, 164 | # relative to this directory. They are copied after the builtin static files, 165 | # so a file named "default.css" will overwrite the builtin "default.css". 166 | html_static_path = ["_static"] 167 | 168 | # Add any extra paths that contain custom files (such as robots.txt or 169 | # .htaccess) here, relative to this directory. These files are copied 170 | # directly to the root of the documentation. 171 | # html_extra_path = [] 172 | 173 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 174 | # using the given strftime format. 175 | # html_last_updated_fmt = '%b %d, %Y' 176 | 177 | # If true, SmartyPants will be used to convert quotes and dashes to 178 | # typographically correct entities. 179 | html_use_smartypants = True 180 | 181 | # Custom sidebar templates, maps document names to template names. 182 | # html_sidebars = {} 183 | 184 | # Additional templates that should be rendered to pages, maps page names to 185 | # template names. 186 | # html_additional_pages = {} 187 | 188 | # If false, no module index is generated. 189 | # html_domain_indices = True 190 | 191 | # If false, no index is generated. 192 | # html_use_index = True 193 | 194 | # If true, the index is split into individual pages for each letter. 195 | # html_split_index = False 196 | 197 | # If true, links to the reST sources are added to the pages. 198 | # html_show_sourcelink = True 199 | 200 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 201 | # html_show_sphinx = True 202 | 203 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 204 | # html_show_copyright = True 205 | 206 | # If true, an OpenSearch description file will be output, and all pages will 207 | # contain a tag referring to it. The value of this option must be the 208 | # base URL from which the finished HTML is served. 209 | # html_use_opensearch = '' 210 | 211 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 212 | # html_file_suffix = None 213 | 214 | # Language to be used for generating the HTML full-text search index. 215 | # Sphinx supports the following languages: 216 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 217 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 218 | # html_search_language = 'en' 219 | 220 | # A dictionary with options for the search language support, empty by default. 221 | # Now only 'ja' uses this config value 222 | # html_search_options = {'type': 'default'} 223 | 224 | # The name of a javascript file (relative to the configuration directory) that 225 | # implements a search results scorer. If empty, the default will be used. 226 | # html_search_scorer = 'scorer.js' 227 | 228 | # Output file base name for HTML help builder. 229 | htmlhelp_basename = "django-cryptographydoc" 230 | 231 | # -- Options for LaTeX output --------------------------------------------- 232 | 233 | latex_elements = { 234 | # The paper size ('letterpaper' or 'a4paper'). 235 | # 'papersize': 'letterpaper', 236 | # The font size ('10pt', '11pt' or '12pt'). 237 | # 'pointsize': '10pt', 238 | # Additional stuff for the LaTeX preamble. 239 | # 'preamble': '', 240 | # Latex figure (float) alignment 241 | # 'figure_align': 'htbp', 242 | } 243 | 244 | # Grouping the document tree into LaTeX files. List of tuples 245 | # (source start file, target name, title, 246 | # author, documentclass [howto, manual, or own class]). 247 | latex_documents = [ 248 | ( 249 | master_doc, 250 | "django-cryptography.tex", 251 | "django-cryptography Documentation", 252 | "George Marshall", 253 | "manual", 254 | ), 255 | ] 256 | 257 | # The name of an image file (relative to this directory) to place at the top of 258 | # the title page. 259 | # latex_logo = None 260 | 261 | # For "manual" documents, if this is true, then toplevel headings are parts, 262 | # not chapters. 263 | # latex_use_parts = False 264 | 265 | # If true, show page references after internal links. 266 | # latex_show_pagerefs = False 267 | 268 | # If true, show URL addresses after external links. 269 | # latex_show_urls = False 270 | 271 | # Documents to append as an appendix to all manuals. 272 | # latex_appendices = [] 273 | 274 | # If false, no module index is generated. 275 | # latex_domain_indices = True 276 | 277 | # -- Options for manual page output --------------------------------------- 278 | 279 | # One entry per manual page. List of tuples 280 | # (source start file, name, description, authors, manual section). 281 | man_pages = [ 282 | ( 283 | master_doc, 284 | "django-cryptography", 285 | "django-cryptography Documentation", 286 | [author], 287 | 1, 288 | ) 289 | ] 290 | 291 | # If true, show URL addresses after external links. 292 | # man_show_urls = False 293 | 294 | # -- Options for Texinfo output ------------------------------------------- 295 | 296 | # Grouping the document tree into Texinfo files. List of tuples 297 | # (source start file, target name, title, author, 298 | # dir menu entry, description, category) 299 | texinfo_documents = [ 300 | ( 301 | master_doc, 302 | "django-cryptography", 303 | "django-cryptography Documentation", 304 | author, 305 | "django-cryptography", 306 | "One line description of project.", 307 | "Miscellaneous", 308 | ), 309 | ] 310 | 311 | # Documents to append as an appendix to all manuals. 312 | # texinfo_appendices = [] 313 | 314 | # If false, no module index is generated. 315 | # texinfo_domain_indices = True 316 | 317 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 318 | # texinfo_show_urls = 'footnote' 319 | 320 | # If true, do not generate a @detailmenu in the "Top" node's menu. 321 | # texinfo_no_detailmenu = False 322 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Cryptography by example 2 | ======================= 3 | 4 | Using symmetrical encryption to store sensitive data in the database. 5 | Wrap the desired model field with 6 | :func:`~django_cryptography.fields.encrypt` to easily protect its 7 | contents. 8 | 9 | .. code-block:: python 10 | 11 | from django.db import models 12 | 13 | from django_cryptography.fields import encrypt 14 | 15 | 16 | class MyModel(models.Model): 17 | name = models.CharField(max_length=50) 18 | sensitive_data = encrypt(models.CharField(max_length=50)) 19 | 20 | The data will now be automatically encrypted when saved to the 21 | database. :func:`~django_cryptography.fields.encrypt` uses an 22 | encryption that allows for bi-directional data retrieval. 23 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ====== 3 | 4 | .. currentmodule:: django_cryptography.fields 5 | 6 | .. autofunction:: encrypt 7 | 8 | .. autoclass:: PickledField 9 | :members: 10 | 11 | Constants 12 | --------- 13 | 14 | .. autodata:: Expired 15 | 16 | Helpers 17 | ------- 18 | 19 | .. autofunction:: get_encrypted_field 20 | 21 | .. autoclass:: EncryptedMixin 22 | :members: 23 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to django-cryptography 2 | ============================== 3 | 4 | A set of primitives for easily encrypting data in Django, wrapping 5 | the Python Cryptography_ library. Also provided is a drop in 6 | replacement for Django's own cryptographic primitives, using 7 | Cryptography_ as the backend provider. 8 | 9 | Why another encryption library for Django? 10 | ------------------------------------------ 11 | 12 | The motivation for making django-cryptography_ was from the 13 | general frustration of existing solutions. Libraries such as 14 | django-cryptographic-fields_ and django-crypto-fields_ do not allow 15 | a way to easily work with custom fields, being limited to their own 16 | provided subset. As well as many others lacking Python 3 and modern 17 | Django support. 18 | 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | installation 24 | settings 25 | fields 26 | migrating 27 | examples 28 | releases 29 | 30 | 31 | Indices and tables 32 | ================== 33 | 34 | * :ref:`genindex` 35 | * :ref:`modindex` 36 | * :ref:`search` 37 | 38 | .. _Cryptography: https://cryptography.io/ 39 | .. _django-cryptography: https://github.com/georgemarshall/django-cryptography/ 40 | .. _django-crypto-fields: https://github.com/erikvw/django-crypto-fields 41 | .. _django-cryptographic-fields: https://github.com/foundertherapy/django-cryptographic-fields/ 42 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ------------ 6 | 7 | * Python_ (3.7, 3.8, 3.9, 3.10, 3.11) 8 | * Cryptography_ (2.0+) 9 | * Django_ (3.2, 4.1, 4.2) 10 | 11 | .. code-block:: console 12 | 13 | pip install django-cryptography 14 | 15 | .. _Cryptography: https://cryptography.io/ 16 | .. _Django: https://www.djangoproject.com/ 17 | .. _Python: https://www.python.org/ 18 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 1>NUL 2>NUL 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-cryptography.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-cryptography.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/migrating.rst: -------------------------------------------------------------------------------- 1 | Migrating existing data 2 | ======================= 3 | 4 | .. seealso:: 5 | 6 | If you are unfamiliar with migrations in Django, please consult 7 | the `Django Migrations`_ documentation. 8 | 9 | To migrate an unencrypted database field to an encrypted field the 10 | following steps must be followed. Each step is labeled with its 11 | Django migration type of schema or data. 12 | 13 | 1. Rename existing field using a prefix such as ``old_`` (schema) 14 | 2. Add new encrypted field with name of the original field (schema) 15 | 3. Copy data from the old field into the new field (data) 16 | 4. Remove the old field (schema) 17 | 18 | The steps are illustrated bellow for the following model: 19 | 20 | .. code-block:: python 21 | 22 | class EncryptedCharModel(models.Model): 23 | field = encrypt(models.CharField(max_length=15)) 24 | 25 | Create the initial migration for the `EncryptedCharModel`. 26 | 27 | .. code-block:: python 28 | 29 | class Migration(migrations.Migration): 30 | 31 | initial = True 32 | 33 | dependencies = [] 34 | 35 | operations = [ 36 | migrations.CreateModel( 37 | name='EncryptedCharModel', 38 | fields=[ 39 | ('id', models.AutoField( 40 | auto_created=True, 41 | primary_key=True, 42 | serialize=False, 43 | verbose_name='ID')), 44 | ('field', models.CharField(max_length=15)), 45 | ], 46 | ), 47 | ] 48 | 49 | Rename the old field by pre-fixing as ``old_field`` from ``field`` 50 | 51 | .. code-block:: python 52 | 53 | class Migration(migrations.Migration): 54 | 55 | dependencies = [ 56 | ('fields', '0001_initial'), 57 | ] 58 | 59 | operations = [ 60 | migrations.RenameField( 61 | model_name='encryptedcharmodel', 62 | old_name='field', 63 | new_name='old_field', 64 | ), 65 | ] 66 | 67 | Add the new encrypted field using the original name from our field. 68 | 69 | .. code-block:: python 70 | 71 | class Migration(migrations.Migration): 72 | 73 | dependencies = [ 74 | ('fields', '0002_rename_fields'), 75 | ] 76 | 77 | operations = [ 78 | migrations.AddField( 79 | model_name='encryptedcharmodel', 80 | name='field', 81 | field=django_cryptography.fields.encrypt( 82 | models.CharField(default=None, max_length=15)), 83 | preserve_default=False, 84 | ), 85 | ] 86 | 87 | Copy the data from the old field into the new field using the ORM. 88 | Providing forwards and reverse methods will allow restoring the field 89 | to its unencrypted form. 90 | 91 | .. code-block:: python 92 | 93 | def forwards_encrypted_char(apps, schema_editor): 94 | EncryptedCharModel = apps.get_model("fields", "EncryptedCharModel") 95 | 96 | for row in EncryptedCharModel.objects.all(): 97 | row.field = row.old_field 98 | row.save(update_fields=["field"]) 99 | 100 | 101 | def reverse_encrypted_char(apps, schema_editor): 102 | EncryptedCharModel = apps.get_model("fields", "EncryptedCharModel") 103 | 104 | for row in EncryptedCharModel.objects.all(): 105 | row.old_field = row.field 106 | row.save(update_fields=["old_field"]) 107 | 108 | 109 | class Migration(migrations.Migration): 110 | 111 | dependencies = [ 112 | ("fields", "0003_add_encrypted_fields"), 113 | ] 114 | 115 | operations = [ 116 | migrations.RunPython(forwards_encrypted_char, reverse_encrypted_char), 117 | ] 118 | 119 | Delete the old field now that the data has been copied into the new field 120 | 121 | .. code-block:: python 122 | 123 | class Migration(migrations.Migration): 124 | 125 | dependencies = [ 126 | ('fields', '0004_migrate_data'), 127 | ] 128 | 129 | operations = [ 130 | migrations.RemoveField( 131 | model_name='encryptedcharmodel', 132 | name='old_field', 133 | ), 134 | ] 135 | 136 | .. _`Django Migrations`: https://docs.djangoproject.com/en/stable/topics/migrations/ 137 | -------------------------------------------------------------------------------- /docs/releases.rst: -------------------------------------------------------------------------------- 1 | Releases 2 | ======== 3 | 4 | 2.0 - master_ 5 | ------------- 6 | 7 | * Dropped Python 3.6 support 8 | 9 | 1.1 - 2022-04-05 10 | ---------------- 11 | 12 | * Added Django 4.0 support 13 | * Dropped Python 3.5 support 14 | * Dropped Django 1.11 support 15 | * Dropped Django 3.1 support 16 | 17 | 1.0 - 2020-02-09 18 | ---------------- 19 | 20 | * Added Django 3.0 support 21 | * Dropped Django 2.1 support 22 | * Dropped Python 2.7 support 23 | * Removed legacy support code 24 | 25 | 0.4 - 2020-01-28 26 | ---------------- 27 | 28 | * Dropped Django 1.8 and 2.0 support 29 | * Fixed Django 3.0 deprecation warning 30 | * Fixed migration test cases 31 | 32 | 33 | 0.3 - 2017-12-19 34 | ---------------- 35 | 36 | * Fixed issue with Django migration generation 37 | * Added initial support for Django 2.0 38 | * Dropped Python 3.3 support 39 | 40 | 0.2 - 2016-12-06 41 | ---------------- 42 | 43 | * Refactored :class:`~django_cryptography.fields.EncryptedField` into 44 | :func:`~django_cryptography.fields.encrypt` decorator. 45 | 46 | 0.1 - 2016-05-21 47 | ---------------- 48 | 49 | * Initial release 50 | 51 | .. _master: https://github.com/georgemarshall/django-cryptography 52 | .. _0.1.x: https://github.com/georgemarshall/django-cryptography/tree/stable/0.1.x 53 | .. _0.2.x: https://github.com/georgemarshall/django-cryptography/tree/stable/0.2.x 54 | .. _0.3.x: https://github.com/georgemarshall/django-cryptography/tree/stable/0.3.x 55 | .. _0.4.x: https://github.com/georgemarshall/django-cryptography/tree/stable/0.4.x 56 | .. _1.0.x: https://github.com/georgemarshall/django-cryptography/tree/stable/1.0.x 57 | .. _1.1.x: https://github.com/georgemarshall/django-cryptography/tree/stable/1.1.x 58 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | :const:`CRYPTOGRAPHY_BACKEND` 5 | ----------------------------- 6 | 7 | Default: :func:`cryptography.hazmat.backends.default_backend` 8 | 9 | :const:`CRYPTOGRAPHY_DIGEST` 10 | ---------------------------- 11 | 12 | Default: :class:`cryptography.hazmat.primitives.hashes.SHA256` 13 | 14 | The digest algorithm to use for signing and key generation. 15 | 16 | :const:`CRYPTOGRAPHY_KEY` 17 | ------------------------- 18 | 19 | Default: :obj:`None` 20 | 21 | When value is :obj:`None` a key will be derived from 22 | ``SECRET_KEY``. Otherwise the value will be used for the key. 23 | 24 | :const:`CRYPTOGRAPHY_SALT` 25 | -------------------------- 26 | 27 | Default: ``'django-cryptography'`` 28 | 29 | Drop-in Replacements 30 | -------------------- 31 | 32 | :const:`SIGNING_BACKEND` 33 | ^^^^^^^^^^^^^^^^^^^^^^^^ 34 | 35 | The default can be replaced with a a Cryptography_ based version. 36 | 37 | .. code-block:: python 38 | 39 | SIGNING_BACKEND = 'django_cryptography.core.signing.TimestampSigner' 40 | 41 | .. _Cryptography: https://cryptography.io/ 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['Django', 'setuptools>=40.8.0', 'wheel'] 3 | build-backend = 'setuptools.build_meta' 4 | 5 | [tool.black] 6 | target-version = ['py37'] 7 | 8 | [tool.coverage.run] 9 | branch = true 10 | source = ['django_cryptography'] 11 | 12 | [tool.coverage.report] 13 | exclude_lines = [ 14 | 'if self.debug:', 15 | 'pragma: no cover', 16 | 'raise NotImplementedError', 17 | 'if __name__ == .__main__.:', 18 | ] 19 | ignore_errors = true 20 | omit = ['docs/*', 'tests/*'] 21 | 22 | [tool.django-stubs] 23 | django_settings_module = 'tests.settings' 24 | 25 | [tool.isort] 26 | profile = 'black' 27 | 28 | [tool.mypy] 29 | plugins = ['mypy_django_plugin.main'] 30 | 31 | [[tool.mypy.overrides]] 32 | module = ['appconf'] 33 | ignore_missing_imports = true 34 | 35 | [tool.ruff] 36 | select = [ 37 | "A", # flake8-builtins 38 | "B", # flake8-bugbear 39 | "C4", # flake8-comprehensions 40 | "C90", # mccabe 41 | "E", # pycodestyle (Error) 42 | "ERA", # eradicate 43 | "F", # Pyflakes 44 | "I", # isort 45 | "RUF", # Ruff-specific 46 | "TCH", # flake8-type-checking 47 | "UP", # pyupgrade 48 | "W", # pycodestyle (Warning) 49 | 50 | ] 51 | exclude = ["docs"] 52 | target-version = "py37" 53 | 54 | [tool.ruff.per-file-ignores] 55 | "tests/*" = ["ERA"] 56 | 57 | [tool.tox] 58 | # language=ini 59 | legacy_tox_ini = """ 60 | [tox] 61 | min_version = 4.0 62 | envlist = 63 | {py37,py38,py39,py310}-django32, 64 | {py38,py39,py310}-django41, 65 | {py38,py39,py310,py311}-django42, 66 | {py310,py311}-djangomain, 67 | isolated_build = True 68 | 69 | [testenv] 70 | deps = 71 | coverage[toml] 72 | django32: Django>=3.2,<3.3 73 | django41: Django>=4.1,<4.2 74 | django42: Django>=4.2,<4.3 75 | djangomain: https://github.com/django/django/archive/main.tar.gz 76 | commands = 77 | {envpython} -m coverage run --context='{envname}' runtests.py {posargs} 78 | """ 79 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import django 5 | from django.conf import settings 6 | from django.test.utils import get_runner 7 | 8 | 9 | def main(): 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 11 | 12 | django.setup() 13 | 14 | TestRunner = get_runner(settings) 15 | test_runner = TestRunner() 16 | failures = test_runner.run_tests(["tests"]) 17 | dbfile = settings.DATABASES.get("default", {}).get("NAME") 18 | if dbfile and os.path.exists(dbfile): 19 | os.remove(dbfile) 20 | sys.exit(bool(failures)) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-cryptography 3 | version = attr: django_cryptography.__version__ 4 | url = https://github.com/georgemarshall/django-cryptography 5 | author = George Marshall 6 | author_email = george@georgemarshall.name 7 | description = Easily encrypt data in Django 8 | long_description = file: README.rst 9 | long_description_content_type = text/x-rst 10 | license = BSD-3-Clause 11 | license_files = LICENSE 12 | classifiers = 13 | Development Status :: 2 - Pre-Alpha 14 | Environment :: Web Environment 15 | Framework :: Django 16 | Framework :: Django :: 2.2 17 | Framework :: Django :: 3.2 18 | Framework :: Django :: 4.0 19 | Intended Audience :: Developers 20 | License :: OSI Approved :: BSD License 21 | Operating System :: OS Independent 22 | Programming Language :: Python 23 | Programming Language :: Python :: 3 24 | Programming Language :: Python :: 3.7 25 | Programming Language :: Python :: 3.8 26 | Programming Language :: Python :: 3.9 27 | Programming Language :: Python :: 3.10 28 | Programming Language :: Python :: 3 :: Only 29 | Topic :: Internet :: WWW/HTTP 30 | Topic :: Security :: Cryptography 31 | project_urls = 32 | Bug Reports = https://github.com/georgemarshall/django-cryptography/issues 33 | Source = https://github.com/georgemarshall/django-cryptography 34 | Documentation = https://django-cryptography.readthedocs.io 35 | 36 | [options] 37 | packages = django_cryptography 38 | python_requires = >=3.7 39 | include_package_data = True 40 | install_requires = 41 | Django>=2.2 42 | cryptography>=3.4.4 43 | django-appconf 44 | typing-extensions>=3.7.4.3 45 | 46 | [options.extras_require] 47 | mypy = 48 | django-stubs 49 | mypy 50 | 51 | [options.package_data] 52 | django_cryptography = py.typed 53 | 54 | [bdist_wheel] 55 | universal = 1 56 | 57 | [flake8] 58 | max-line-length = 88 59 | extend-ignore = E203 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/__init__.py -------------------------------------------------------------------------------- /tests/fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/fields/__init__.py -------------------------------------------------------------------------------- /tests/fields/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_cryptography.fields import PickledField, encrypt 4 | 5 | 6 | class PickledModel(models.Model): 7 | field = PickledField() 8 | 9 | 10 | class DefaultPickledModel(models.Model): 11 | field = PickledField(default=b"") 12 | 13 | 14 | class NullablePickledModel(models.Model): 15 | field = PickledField(blank=True, null=True) 16 | 17 | 18 | class EncryptedIntegerModel(models.Model): 19 | field = encrypt(models.IntegerField()) 20 | 21 | 22 | class EncryptedNullableIntegerModel(models.Model): 23 | field = encrypt(models.IntegerField(blank=True, null=True)) 24 | 25 | 26 | class EncryptedTTLIntegerModel(models.Model): 27 | field = encrypt(models.IntegerField(), ttl=60) 28 | 29 | 30 | class EncryptedCharModel(models.Model): 31 | field = encrypt(models.CharField(max_length=15)) 32 | 33 | 34 | class EncryptedDateTimeModel(models.Model): 35 | datetime = encrypt(models.DateTimeField()) 36 | date = encrypt(models.DateField()) 37 | time = encrypt(models.TimeField()) 38 | auto_now = encrypt(models.DateTimeField(auto_now=True)) 39 | 40 | 41 | class OtherEncryptedTypesModel(models.Model): 42 | ip = encrypt(models.GenericIPAddressField()) 43 | uuid = encrypt(models.UUIDField()) 44 | decimal = encrypt(models.DecimalField(max_digits=5, decimal_places=2)) 45 | 46 | 47 | @encrypt 48 | class EncryptedFieldSubclass(models.IntegerField): 49 | pass 50 | -------------------------------------------------------------------------------- /tests/fields/test_encrypted.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import json 3 | import uuid 4 | from io import StringIO 5 | 6 | from django import forms 7 | from django.conf import settings 8 | from django.core import exceptions, serializers, validators 9 | from django.core.management import call_command 10 | from django.db import IntegrityError, connection, models 11 | from django.test import TestCase, TransactionTestCase, override_settings 12 | from django.test.utils import freeze_time 13 | from django.utils import timezone 14 | 15 | from django_cryptography.fields import Expired, encrypt 16 | 17 | from .models import ( 18 | EncryptedCharModel, 19 | EncryptedDateTimeModel, 20 | EncryptedFieldSubclass, 21 | EncryptedIntegerModel, 22 | EncryptedNullableIntegerModel, 23 | EncryptedTTLIntegerModel, 24 | OtherEncryptedTypesModel, 25 | ) 26 | 27 | 28 | class TestSaveLoad(TestCase): 29 | def test_integer(self): 30 | instance = EncryptedIntegerModel(field=42) 31 | instance.save() 32 | loaded = EncryptedIntegerModel.objects.get() 33 | self.assertEqual(instance.field, loaded.field) 34 | 35 | def test_char(self): 36 | instance = EncryptedCharModel(field="Hello, world!") 37 | instance.save() 38 | loaded = EncryptedCharModel.objects.get() 39 | self.assertEqual(instance.field, loaded.field) 40 | 41 | def test_dates(self): 42 | instance = EncryptedDateTimeModel( 43 | datetime=timezone.now(), 44 | date=timezone.now().date(), 45 | time=timezone.now().time(), 46 | ) 47 | instance.save() 48 | loaded = EncryptedDateTimeModel.objects.get() 49 | self.assertEqual(instance.datetime, loaded.datetime) 50 | self.assertEqual(instance.date, loaded.date) 51 | self.assertEqual(instance.time, loaded.time) 52 | self.assertTrue(instance.auto_now) 53 | self.assertEqual(instance.auto_now, loaded.auto_now) 54 | 55 | def test_default_null(self): 56 | instance = EncryptedNullableIntegerModel() 57 | instance.save() 58 | loaded = EncryptedNullableIntegerModel.objects.get(pk=instance.pk) 59 | self.assertEqual(loaded.field, None) 60 | self.assertEqual(instance.field, loaded.field) 61 | 62 | def test_null_handling(self): 63 | instance = EncryptedNullableIntegerModel(field=None) 64 | instance.save() 65 | loaded = EncryptedNullableIntegerModel.objects.get() 66 | self.assertEqual(instance.field, loaded.field) 67 | 68 | instance = EncryptedIntegerModel(field=None) 69 | with self.assertRaises(IntegrityError): 70 | instance.save() 71 | 72 | def test_ttl(self): 73 | with freeze_time(499162800): 74 | instance = EncryptedTTLIntegerModel(field=42) 75 | instance.save() 76 | 77 | with freeze_time(123456789): 78 | loaded = EncryptedTTLIntegerModel.objects.get() 79 | self.assertIs(loaded.field, Expired) 80 | 81 | def test_other_types(self): 82 | instance = OtherEncryptedTypesModel( 83 | ip="192.168.0.1", 84 | uuid=uuid.uuid4(), 85 | decimal=decimal.Decimal(1.25), 86 | ) 87 | instance.save() 88 | loaded = OtherEncryptedTypesModel.objects.get() 89 | self.assertEqual(instance.ip, loaded.ip) 90 | self.assertEqual(instance.uuid, loaded.uuid) 91 | self.assertEqual(instance.decimal, loaded.decimal) 92 | 93 | def test_updates(self): 94 | with self.assertNumQueries(2): 95 | instance = EncryptedCharModel.objects.create(field="Hello, world!") 96 | instance.field = "Goodbye, world!" 97 | instance.save() 98 | loaded = EncryptedCharModel.objects.get() 99 | self.assertEqual(instance.field, loaded.field) 100 | 101 | 102 | class TestQuerying(TestCase): 103 | def setUp(self): 104 | self.objs = [ 105 | EncryptedNullableIntegerModel.objects.create(field=1), 106 | EncryptedNullableIntegerModel.objects.create(field=2), 107 | EncryptedNullableIntegerModel.objects.create(field=3), 108 | EncryptedNullableIntegerModel.objects.create(field=None), 109 | ] 110 | 111 | def test_isnull(self): 112 | self.assertSequenceEqual( 113 | self.objs[-1:], 114 | EncryptedNullableIntegerModel.objects.filter(field__isnull=True), 115 | ) 116 | 117 | def test_unsupported(self): 118 | with self.assertRaises(exceptions.FieldError): 119 | EncryptedNullableIntegerModel.objects.filter(field__exact=2) 120 | 121 | 122 | class TestChecks(TestCase): 123 | def test_settings_has_key(self): 124 | key = settings.CRYPTOGRAPHY_KEY 125 | self.assertIsNotNone(key) 126 | self.assertIsInstance(key, bytes) 127 | 128 | def test_field_description(self): 129 | field = encrypt(models.IntegerField()) 130 | self.assertEqual("Encrypted Integer", field.description) 131 | 132 | def test_field_checks(self): 133 | class BadField(models.Model): 134 | field = encrypt(models.CharField()) 135 | 136 | class Meta: 137 | app_label = "myapp" 138 | 139 | model = BadField() 140 | errors = model.check() 141 | self.assertEqual(len(errors), 1) 142 | # The inner CharField is missing a max_length. 143 | self.assertEqual("fields.E120", errors[0].id) 144 | self.assertIn("max_length", errors[0].msg) 145 | 146 | def test_invalid_base_fields(self): 147 | class Related(models.Model): 148 | field = encrypt( 149 | models.ForeignKey("fields.EncryptedIntegerModel", models.CASCADE) 150 | ) 151 | 152 | class Meta: 153 | app_label = "myapp" 154 | 155 | obj = Related() 156 | errors = obj.check() 157 | self.assertEqual(1, len(errors)) 158 | self.assertEqual("encrypted.E002", errors[0].id) 159 | 160 | 161 | class TestMigrations(TransactionTestCase): 162 | available_apps = ["tests.fields"] 163 | 164 | def test_clone(self): 165 | field = encrypt(models.IntegerField()) 166 | new_field = field.clone() 167 | self.assertIsNot(field, new_field) 168 | self.assertEqual(field.verbose_name, new_field.verbose_name) 169 | self.assertNotEqual(field.creation_counter, new_field.creation_counter) 170 | 171 | def test_subclass_clone(self): 172 | field = EncryptedFieldSubclass() 173 | new_field = field.clone() 174 | self.assertIsNot(field, new_field) 175 | self.assertEqual(field.verbose_name, new_field.verbose_name) 176 | self.assertNotEqual(field.creation_counter, new_field.creation_counter) 177 | 178 | def test_deconstruct(self): 179 | field = encrypt(models.IntegerField()) 180 | name, path, args, kwargs = field.deconstruct() 181 | new = encrypt(*args, **kwargs) 182 | self.assertEqual(type(new), type(field)) 183 | 184 | def test_deconstruct_with_ttl(self): 185 | field = encrypt(models.IntegerField(), ttl=60) 186 | name, path, args, kwargs = field.deconstruct() 187 | new = encrypt(*args, **kwargs) 188 | self.assertEqual(new.ttl, field.ttl) 189 | 190 | def test_deconstruct_args(self): 191 | field = encrypt(models.CharField(max_length=20)) 192 | name, path, args, kwargs = field.deconstruct() 193 | new = encrypt(*args, **kwargs) 194 | self.assertEqual(new.max_length, field.max_length) 195 | 196 | def test_subclass_deconstruct(self): 197 | field = encrypt(models.IntegerField()) 198 | name, path, args, kwargs = field.deconstruct() 199 | self.assertEqual("django_cryptography.fields.encrypt", path) 200 | 201 | field = EncryptedFieldSubclass() 202 | name, path, args, kwargs = field.deconstruct() 203 | self.assertEqual("tests.fields.models.EncryptedFieldSubclass", path) 204 | 205 | @override_settings( 206 | MIGRATION_MODULES={"fields": "tests.fields.test_migrations_encrypted_default"} 207 | ) 208 | def test_adding_field_with_default(self): 209 | table_name = "fields_integerencrypteddefaultmodel" 210 | with connection.cursor() as cursor: 211 | self.assertNotIn(table_name, connection.introspection.table_names(cursor)) 212 | call_command("migrate", "fields", verbosity=0) 213 | with connection.cursor() as cursor: 214 | self.assertIn(table_name, connection.introspection.table_names(cursor)) 215 | call_command("migrate", "fields", "zero", verbosity=0) 216 | with connection.cursor() as cursor: 217 | self.assertNotIn(table_name, connection.introspection.table_names(cursor)) 218 | 219 | @override_settings( 220 | MIGRATION_MODULES={"fields": "tests.fields.test_migrations_normal_to_encrypted"} 221 | ) 222 | def test_makemigrations_no_changes(self): 223 | out = StringIO() 224 | call_command("makemigrations", "--dry-run", "fields", stdout=out) 225 | self.assertIn("No changes detected in app 'fields'", out.getvalue()) 226 | 227 | 228 | class TestSerialization(TestCase): 229 | test_data_integer = ( 230 | '[{"fields": {"field": 42}, ' 231 | '"model": "fields.encryptedintegermodel", "pk": null}]' 232 | ) 233 | test_data_char = ( 234 | '[{"fields": {"field": "Hello, world!"}, ' 235 | '"model": "fields.encryptedcharmodel", "pk": null}]' 236 | ) 237 | 238 | def test_integer_dumping(self): 239 | instance = EncryptedIntegerModel(field=42) 240 | data = serializers.serialize("json", [instance]) 241 | self.assertEqual(json.loads(self.test_data_integer), json.loads(data)) 242 | 243 | def test_integer_loading(self): 244 | instance = list(serializers.deserialize("json", self.test_data_integer))[ 245 | 0 246 | ].object 247 | self.assertEqual(42, instance.field) 248 | 249 | def test_char_dumping(self): 250 | instance = EncryptedCharModel(field="Hello, world!") 251 | data = serializers.serialize("json", [instance]) 252 | self.assertEqual(json.loads(self.test_data_char), json.loads(data)) 253 | 254 | def test_char_loading(self): 255 | instance = list(serializers.deserialize("json", self.test_data_char))[0].object 256 | self.assertEqual("Hello, world!", instance.field) 257 | 258 | 259 | class TestValidation(TestCase): 260 | def test_unbounded(self): 261 | field = encrypt(models.IntegerField()) 262 | with self.assertRaises(exceptions.ValidationError) as cm: 263 | field.clean(None, None) 264 | self.assertEqual("null", cm.exception.code) 265 | self.assertEqual("This field cannot be null.", cm.exception.messages[0]) 266 | 267 | def test_blank_true(self): 268 | field = encrypt(models.IntegerField(blank=True, null=True)) 269 | # This should not raise a validation error 270 | field.clean(None, None) 271 | 272 | def test_with_validators(self): 273 | field = encrypt( 274 | models.IntegerField(validators=[validators.MinValueValidator(1)]) 275 | ) 276 | field.clean(1, None) 277 | with self.assertRaises(exceptions.ValidationError) as cm: 278 | field.clean(0, None) 279 | self.assertEqual( 280 | "Ensure this value is greater than or equal to 1.", cm.exception.messages[0] 281 | ) 282 | 283 | 284 | class TestFormField(TestCase): 285 | class EncryptedCharModelForm(forms.ModelForm): 286 | class Meta: 287 | fields = "__all__" 288 | model = EncryptedCharModel 289 | 290 | def test_model_field_formfield(self): 291 | model_field = encrypt(models.CharField(max_length=27)) 292 | form_field = model_field.formfield() 293 | self.assertIsInstance(form_field, forms.CharField) 294 | self.assertEqual(form_field.max_length, 27) 295 | 296 | def test_model_form(self): 297 | data = {"field": "Hello, world!"} 298 | form = self.EncryptedCharModelForm(data) 299 | self.assertTrue(form.is_valid(), form.errors) 300 | self.assertEqual({"field": "Hello, world!"}, form.cleaned_data) 301 | 302 | instance = form.save() 303 | loaded = EncryptedCharModel.objects.get() 304 | self.assertEqual(instance.field, loaded.field) 305 | 306 | def test_model_form_update(self): 307 | data = {"field": "Goodbye, world!"} 308 | instance = EncryptedCharModel.objects.create(field="Hello, world!") 309 | form = self.EncryptedCharModelForm(data, instance=instance) 310 | self.assertTrue(form.is_valid(), form.errors) 311 | form.save() 312 | 313 | loaded = EncryptedCharModel.objects.get() 314 | self.assertEqual(data["field"], loaded.field) 315 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_encrypted_default/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | import django_cryptography.fields 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="IntegerEncryptedDefaultModel", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | verbose_name="ID", 19 | serialize=False, 20 | auto_created=True, 21 | primary_key=True, 22 | ), 23 | ), 24 | ("field", django_cryptography.fields.encrypt(models.IntegerField())), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_encrypted_default/0002_integerencryptedmodel_field_2.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | import django_cryptography.fields 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("fields", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="integerencrypteddefaultmodel", 14 | name="field_2", 15 | field=django_cryptography.fields.encrypt( 16 | models.IntegerField(max_length=50, blank=True) 17 | ), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_encrypted_default/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/fields/test_migrations_encrypted_default/__init__.py -------------------------------------------------------------------------------- /tests/fields/test_migrations_normal_to_encrypted/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | import django_cryptography.fields 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="EncryptedCharModel", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("field", models.CharField(max_length=15)), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name="EncryptedDateTimeModel", 29 | fields=[ 30 | ( 31 | "id", 32 | models.AutoField( 33 | auto_created=True, 34 | primary_key=True, 35 | serialize=False, 36 | verbose_name="ID", 37 | ), 38 | ), 39 | ("datetime", models.DateTimeField()), 40 | ("date", models.DateField()), 41 | ("time", models.TimeField()), 42 | ("auto_now", models.DateTimeField(auto_now=True)), 43 | ], 44 | ), 45 | migrations.CreateModel( 46 | name="EncryptedIntegerModel", 47 | fields=[ 48 | ( 49 | "id", 50 | models.AutoField( 51 | auto_created=True, 52 | primary_key=True, 53 | serialize=False, 54 | verbose_name="ID", 55 | ), 56 | ), 57 | ("field", models.IntegerField()), 58 | ], 59 | ), 60 | migrations.CreateModel( 61 | name="EncryptedNullableIntegerModel", 62 | fields=[ 63 | ( 64 | "id", 65 | models.AutoField( 66 | auto_created=True, 67 | primary_key=True, 68 | serialize=False, 69 | verbose_name="ID", 70 | ), 71 | ), 72 | ("field", models.IntegerField(blank=True, null=True)), 73 | ], 74 | ), 75 | migrations.CreateModel( 76 | name="EncryptedTTLIntegerModel", 77 | fields=[ 78 | ( 79 | "id", 80 | models.AutoField( 81 | auto_created=True, 82 | primary_key=True, 83 | serialize=False, 84 | verbose_name="ID", 85 | ), 86 | ), 87 | ("field", models.IntegerField()), 88 | ], 89 | ), 90 | migrations.CreateModel( 91 | name="DefaultPickledModel", 92 | fields=[ 93 | ( 94 | "id", 95 | models.AutoField( 96 | auto_created=True, 97 | primary_key=True, 98 | serialize=False, 99 | verbose_name="ID", 100 | ), 101 | ), 102 | ( 103 | "field", 104 | django_cryptography.fields.PickledField(default=b""), 105 | ), 106 | ], 107 | ), 108 | migrations.CreateModel( 109 | name="NullablePickledModel", 110 | fields=[ 111 | ( 112 | "id", 113 | models.AutoField( 114 | auto_created=True, 115 | primary_key=True, 116 | serialize=False, 117 | verbose_name="ID", 118 | ), 119 | ), 120 | ( 121 | "field", 122 | django_cryptography.fields.PickledField(blank=True, null=True), 123 | ), 124 | ], 125 | ), 126 | migrations.CreateModel( 127 | name="OtherEncryptedTypesModel", 128 | fields=[ 129 | ( 130 | "id", 131 | models.AutoField( 132 | auto_created=True, 133 | primary_key=True, 134 | serialize=False, 135 | verbose_name="ID", 136 | ), 137 | ), 138 | ("ip", models.GenericIPAddressField()), 139 | ("uuid", models.UUIDField()), 140 | ("decimal", models.DecimalField(decimal_places=2, max_digits=5)), 141 | ], 142 | ), 143 | migrations.CreateModel( 144 | name="PickledModel", 145 | fields=[ 146 | ( 147 | "id", 148 | models.AutoField( 149 | auto_created=True, 150 | primary_key=True, 151 | serialize=False, 152 | verbose_name="ID", 153 | ), 154 | ), 155 | ("field", django_cryptography.fields.PickledField()), 156 | ], 157 | ), 158 | ] 159 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_normal_to_encrypted/0002_rename_fields.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("fields", "0001_initial"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RenameField( 11 | model_name="encryptedcharmodel", 12 | old_name="field", 13 | new_name="old_field", 14 | ), 15 | migrations.RenameField( 16 | model_name="encrypteddatetimemodel", 17 | old_name="auto_now", 18 | new_name="old_auto_now", 19 | ), 20 | migrations.RenameField( 21 | model_name="encrypteddatetimemodel", 22 | old_name="date", 23 | new_name="old_date", 24 | ), 25 | migrations.RenameField( 26 | model_name="encrypteddatetimemodel", 27 | old_name="datetime", 28 | new_name="old_datetime", 29 | ), 30 | migrations.RenameField( 31 | model_name="encrypteddatetimemodel", 32 | old_name="time", 33 | new_name="old_time", 34 | ), 35 | migrations.RenameField( 36 | model_name="encryptedintegermodel", 37 | old_name="field", 38 | new_name="old_field", 39 | ), 40 | migrations.RenameField( 41 | model_name="encryptednullableintegermodel", 42 | old_name="field", 43 | new_name="old_field", 44 | ), 45 | migrations.RenameField( 46 | model_name="encryptedttlintegermodel", 47 | old_name="field", 48 | new_name="old_field", 49 | ), 50 | migrations.RenameField( 51 | model_name="otherencryptedtypesmodel", 52 | old_name="decimal", 53 | new_name="old_decimal", 54 | ), 55 | migrations.RenameField( 56 | model_name="otherencryptedtypesmodel", 57 | old_name="ip", 58 | new_name="old_ip", 59 | ), 60 | migrations.RenameField( 61 | model_name="otherencryptedtypesmodel", 62 | old_name="uuid", 63 | new_name="old_uuid", 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_normal_to_encrypted/0003_add_encrypted_fields.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | import django_cryptography.fields 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("fields", "0002_rename_fields"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="encryptedcharmodel", 14 | name="field", 15 | field=django_cryptography.fields.encrypt( 16 | models.CharField(default=None, max_length=15) 17 | ), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name="encrypteddatetimemodel", 22 | name="auto_now", 23 | field=django_cryptography.fields.encrypt( 24 | models.DateTimeField(default=None, auto_now=True) 25 | ), 26 | preserve_default=False, 27 | ), 28 | migrations.AddField( 29 | model_name="encrypteddatetimemodel", 30 | name="date", 31 | field=django_cryptography.fields.encrypt(models.DateField(default=None)), 32 | preserve_default=False, 33 | ), 34 | migrations.AddField( 35 | model_name="encrypteddatetimemodel", 36 | name="datetime", 37 | field=django_cryptography.fields.encrypt( 38 | models.DateTimeField(default=None) 39 | ), 40 | preserve_default=False, 41 | ), 42 | migrations.AddField( 43 | model_name="encrypteddatetimemodel", 44 | name="time", 45 | field=django_cryptography.fields.encrypt(models.TimeField(default=None)), 46 | preserve_default=False, 47 | ), 48 | migrations.AddField( 49 | model_name="encryptedintegermodel", 50 | name="field", 51 | field=django_cryptography.fields.encrypt(models.IntegerField(default=None)), 52 | preserve_default=False, 53 | ), 54 | migrations.AddField( 55 | model_name="encryptednullableintegermodel", 56 | name="field", 57 | field=django_cryptography.fields.encrypt( 58 | models.IntegerField(default=None, blank=True, null=True) 59 | ), 60 | preserve_default=False, 61 | ), 62 | migrations.AddField( 63 | model_name="encryptedttlintegermodel", 64 | name="field", 65 | field=django_cryptography.fields.encrypt( 66 | models.IntegerField(default=None), ttl=60 67 | ), 68 | preserve_default=False, 69 | ), 70 | migrations.AddField( 71 | model_name="otherencryptedtypesmodel", 72 | name="decimal", 73 | field=django_cryptography.fields.encrypt( 74 | models.DecimalField(default=None, decimal_places=2, max_digits=5) 75 | ), 76 | preserve_default=False, 77 | ), 78 | migrations.AddField( 79 | model_name="otherencryptedtypesmodel", 80 | name="ip", 81 | field=django_cryptography.fields.encrypt( 82 | models.GenericIPAddressField(default=None) 83 | ), 84 | preserve_default=False, 85 | ), 86 | migrations.AddField( 87 | model_name="otherencryptedtypesmodel", 88 | name="uuid", 89 | field=django_cryptography.fields.encrypt(models.UUIDField(default=None)), 90 | preserve_default=False, 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_normal_to_encrypted/0004_migrate_data.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def forwards_encrypted_char(apps, schema_editor): 5 | EncryptedCharModel = apps.get_model("fields", "EncryptedCharModel") 6 | 7 | for row in EncryptedCharModel.objects.all(): 8 | row.field = row.old_field 9 | row.save(update_fields=["field"]) 10 | 11 | 12 | def reverse_encrypted_char(apps, schema_editor): 13 | EncryptedCharModel = apps.get_model("fields", "EncryptedCharModel") 14 | 15 | for row in EncryptedCharModel.objects.all(): 16 | row.old_field = row.field 17 | row.save(update_fields=["old_field"]) 18 | 19 | 20 | def forwards_encrypted_date_time(apps, schema_editor): 21 | EncryptedDateTimeModel = apps.get_model("fields", "EncryptedDateTimeModel") 22 | 23 | for row in EncryptedDateTimeModel.objects.all(): 24 | row.auto_now = row.old_auto_now 25 | row.date = row.old_date 26 | row.datetime = row.old_datetime 27 | row.time = row.old_time 28 | row.save(update_fields=["auto_now", "date", "datetime", "time"]) 29 | 30 | 31 | def reverse_encrypted_date_time(apps, schema_editor): 32 | EncryptedDateTimeModel = apps.get_model("fields", "EncryptedDateTimeModel") 33 | 34 | for row in EncryptedDateTimeModel.objects.all(): 35 | row.old_auto_now = row.auto_now 36 | row.old_date = row.date 37 | row.old_datetime = row.datetime 38 | row.old_time = row.time 39 | row.save(update_fields=["old_auto_now", "old_date", "old_datetime", "old_time"]) 40 | 41 | 42 | def forwards_encrypted_integer(apps, schema_editor): 43 | EncryptedIntegerModel = apps.get_model("fields", "EncryptedIntegerModel") 44 | 45 | for row in EncryptedIntegerModel.objects.all(): 46 | row.field = row.old_field 47 | row.save(update_fields=["field"]) 48 | 49 | 50 | def reverse_encrypted_integer(apps, schema_editor): 51 | EncryptedIntegerModel = apps.get_model("fields", "EncryptedIntegerModel") 52 | 53 | for row in EncryptedIntegerModel.objects.all(): 54 | row.old_field = row.field 55 | row.save(update_fields=["old_field"]) 56 | 57 | 58 | def forwards_encrypted_nullable_integer(apps, schema_editor): 59 | EncryptedNullableIntegerModel = apps.get_model( 60 | "fields", "EncryptedNullableIntegerModel" 61 | ) 62 | 63 | for row in EncryptedNullableIntegerModel.objects.all(): 64 | row.field = row.old_field 65 | row.save(update_fields=["field"]) 66 | 67 | 68 | def reverse_encrypted_nullable_integer(apps, schema_editor): 69 | EncryptedNullableIntegerModel = apps.get_model( 70 | "fields", "EncryptedNullableIntegerModel" 71 | ) 72 | 73 | for row in EncryptedNullableIntegerModel.objects.all(): 74 | row.old_field = row.field 75 | row.save(update_fields=["old_field"]) 76 | 77 | 78 | def forwards_encrypted_ttl_integer(apps, schema_editor): 79 | EncryptedTTLIntegerModel = apps.get_model("fields", "EncryptedTTLIntegerModel") 80 | 81 | for row in EncryptedTTLIntegerModel.objects.all(): 82 | row.field = row.old_field 83 | row.save(update_fields=["field"]) 84 | 85 | 86 | def reverse_encrypted_ttl_integer(apps, schema_editor): 87 | EncryptedTTLIntegerModel = apps.get_model("fields", "EncryptedTTLIntegerModel") 88 | 89 | for row in EncryptedTTLIntegerModel.objects.all(): 90 | row.old_field = row.field 91 | row.save(update_fields=["old_field"]) 92 | 93 | 94 | def forwards_other_encrypted_types(apps, schema_editor): 95 | OtherEncryptedTypesModel = apps.get_model("fields", "OtherEncryptedTypesModel") 96 | 97 | for row in OtherEncryptedTypesModel.objects.all(): 98 | row.decimal = row.old_decimal 99 | row.ip = row.old_ip 100 | row.uuid = row.old_uuid 101 | row.save(update_fields=["decimal", "ip", "uuid"]) 102 | 103 | 104 | def reverse_other_encrypted_types(apps, schema_editor): 105 | OtherEncryptedTypesModel = apps.get_model("fields", "OtherEncryptedTypesModel") 106 | 107 | for row in OtherEncryptedTypesModel.objects.all(): 108 | row.old_decimal = row.decimal 109 | row.old_ip = row.ip 110 | row.old_uuid = row.uuid 111 | row.save(update_fields=["old_decimal", "old_ip", "old_uuid"]) 112 | 113 | 114 | class Migration(migrations.Migration): 115 | dependencies = [ 116 | ("fields", "0003_add_encrypted_fields"), 117 | ] 118 | 119 | operations = [ 120 | migrations.RunPython(forwards_encrypted_char, reverse_encrypted_char), 121 | migrations.RunPython(forwards_encrypted_date_time, reverse_encrypted_date_time), 122 | migrations.RunPython(forwards_encrypted_integer, reverse_encrypted_integer), 123 | migrations.RunPython( 124 | forwards_encrypted_nullable_integer, reverse_encrypted_nullable_integer 125 | ), 126 | migrations.RunPython( 127 | forwards_encrypted_ttl_integer, reverse_encrypted_ttl_integer 128 | ), 129 | migrations.RunPython( 130 | forwards_other_encrypted_types, reverse_other_encrypted_types 131 | ), 132 | ] 133 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_normal_to_encrypted/0005_remove_old_fields.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("fields", "0004_migrate_data"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RemoveField( 11 | model_name="encryptedcharmodel", 12 | name="old_field", 13 | ), 14 | migrations.RemoveField( 15 | model_name="encrypteddatetimemodel", 16 | name="old_auto_now", 17 | ), 18 | migrations.RemoveField( 19 | model_name="encrypteddatetimemodel", 20 | name="old_date", 21 | ), 22 | migrations.RemoveField( 23 | model_name="encrypteddatetimemodel", 24 | name="old_datetime", 25 | ), 26 | migrations.RemoveField( 27 | model_name="encrypteddatetimemodel", 28 | name="old_time", 29 | ), 30 | migrations.RemoveField( 31 | model_name="encryptedintegermodel", 32 | name="old_field", 33 | ), 34 | migrations.RemoveField( 35 | model_name="encryptednullableintegermodel", 36 | name="old_field", 37 | ), 38 | migrations.RemoveField( 39 | model_name="encryptedttlintegermodel", 40 | name="old_field", 41 | ), 42 | migrations.RemoveField( 43 | model_name="otherencryptedtypesmodel", 44 | name="old_decimal", 45 | ), 46 | migrations.RemoveField( 47 | model_name="otherencryptedtypesmodel", 48 | name="old_ip", 49 | ), 50 | migrations.RemoveField( 51 | model_name="otherencryptedtypesmodel", 52 | name="old_uuid", 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /tests/fields/test_migrations_normal_to_encrypted/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/fields/test_migrations_normal_to_encrypted/__init__.py -------------------------------------------------------------------------------- /tests/fields/test_pickle.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pickle 3 | 4 | from django.core import exceptions, serializers 5 | from django.db import IntegrityError 6 | from django.test import TestCase 7 | from django.utils import timezone 8 | 9 | from django_cryptography.fields import PickledField 10 | 11 | from .models import DefaultPickledModel, NullablePickledModel, PickledModel 12 | 13 | 14 | class TestSaveLoad(TestCase): 15 | def test_integer(self): 16 | instance = PickledModel(field=42) 17 | instance.save() 18 | loaded = PickledModel.objects.get() 19 | self.assertEqual(instance.field, loaded.field) 20 | 21 | def test_string(self): 22 | instance = PickledModel(field="Hello, world!") 23 | instance.save() 24 | loaded = PickledModel.objects.get() 25 | self.assertEqual(instance.field, loaded.field) 26 | 27 | def test_datetime(self): 28 | instance = PickledModel(field=timezone.now()) 29 | instance.save() 30 | loaded = PickledModel.objects.get() 31 | self.assertEqual(instance.field, loaded.field) 32 | 33 | def test_default(self): 34 | instance = DefaultPickledModel() 35 | instance.save() 36 | loaded = DefaultPickledModel.objects.get(pk=instance.pk) 37 | self.assertEqual(loaded.field, b"") 38 | self.assertEqual(instance.field, loaded.field) 39 | 40 | def test_default_null(self): 41 | instance = NullablePickledModel() 42 | instance.save() 43 | loaded = NullablePickledModel.objects.get(pk=instance.pk) 44 | self.assertEqual(loaded.field, None) 45 | self.assertEqual(instance.field, loaded.field) 46 | 47 | def test_null_handling(self): 48 | instance = NullablePickledModel(field=None) 49 | instance.save() 50 | loaded = NullablePickledModel.objects.get() 51 | self.assertEqual(instance.field, loaded.field) 52 | 53 | instance = PickledModel(field=None) 54 | with self.assertRaises(IntegrityError): 55 | instance.save() 56 | 57 | 58 | class TestQuerying(TestCase): 59 | def setUp(self): 60 | self.objs = [ 61 | NullablePickledModel.objects.create(field=[1]), 62 | NullablePickledModel.objects.create(field=[2]), 63 | NullablePickledModel.objects.create(field=[2, 3]), 64 | NullablePickledModel.objects.create(field=[20, 30, 40]), 65 | NullablePickledModel.objects.create(field=None), 66 | ] 67 | 68 | def test_exact(self): 69 | self.assertSequenceEqual( 70 | NullablePickledModel.objects.filter(field__exact=[1]), self.objs[:1] 71 | ) 72 | 73 | def test_isnull(self): 74 | self.assertSequenceEqual( 75 | NullablePickledModel.objects.filter(field__isnull=True), self.objs[-1:] 76 | ) 77 | 78 | def test_in(self): 79 | self.assertSequenceEqual( 80 | NullablePickledModel.objects.filter(field__in=[[1], [2]]), self.objs[:2] 81 | ) 82 | 83 | def test_unsupported(self): 84 | with self.assertRaises(exceptions.FieldError): 85 | NullablePickledModel.objects.filter(field__contains=[2]) 86 | 87 | 88 | class TestMigrations(TestCase): 89 | def test_deconstruct(self): 90 | field = PickledField() 91 | name, path, args, kwargs = field.deconstruct() 92 | self.assertEqual("django_cryptography.fields.PickledField", path) 93 | self.assertEqual(args, []) 94 | self.assertEqual(kwargs, {}) 95 | 96 | 97 | class TestSerialization(TestCase): 98 | test_data = ( 99 | ( 100 | # Python 3.4 101 | '[{"fields": {"field": "gANdcQAoSwFLAk5lLg=="}, ' 102 | '"model": "fields.pickledmodel", "pk": null}]' 103 | ) 104 | if pickle.HIGHEST_PROTOCOL < 5 105 | else ( 106 | # Python 3.8 107 | '[{"fields": {"field": "gASVCgAAAAAAAABdlChLAUsCTmUu"}, ' 108 | '"model": "fields.pickledmodel", "pk": null}]' 109 | ) 110 | ) 111 | 112 | def test_dumping(self): 113 | instance = PickledModel(field=[1, 2, None]) 114 | data = serializers.serialize("json", [instance]) 115 | self.assertEqual(json.loads(self.test_data), json.loads(data)) 116 | 117 | def test_loading(self): 118 | instance = list(serializers.deserialize("json", self.test_data))[0].object 119 | self.assertEqual([1, 2, None], instance.field) 120 | 121 | 122 | class TestValidation(TestCase): 123 | def test_validate(self): 124 | field = PickledField() 125 | field.clean(None, None) 126 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | "default": { 3 | "ENGINE": "django.db.backends.sqlite3", 4 | } 5 | } 6 | 7 | SECRET_KEY = "django_tests_secret_key" 8 | 9 | INSTALLED_APPS = [ 10 | "tests.fields", 11 | ] 12 | 13 | SIGNING_BACKEND = "django_cryptography.core.signing.TimestampSigner" 14 | 15 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 16 | -------------------------------------------------------------------------------- /tests/signing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/signing/__init__.py -------------------------------------------------------------------------------- /tests/signing/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | 4 | from django.test import SimpleTestCase 5 | from django.test.utils import freeze_time 6 | 7 | from django_cryptography.core import signing 8 | from django_cryptography.utils.crypto import InvalidAlgorithm 9 | 10 | 11 | class TestSigner(SimpleTestCase): 12 | def test_signature(self): 13 | """signature() method should generate a signature""" 14 | signer = signing.Signer("predictable-secret") 15 | signer2 = signing.Signer("predictable-secret2") 16 | for s in ( 17 | b"hello", 18 | b"3098247:529:087:", 19 | "\u2019".encode(), 20 | ): 21 | self.assertEqual( 22 | signer.signature(s), 23 | signing.base64_hmac( 24 | signer.salt + "signer", 25 | s, 26 | "predictable-secret", 27 | algorithm=signer.algorithm, 28 | ), 29 | ) 30 | self.assertNotEqual(signer.signature(s), signer2.signature(s)) 31 | 32 | def test_signature_with_salt(self): 33 | """signature(value, salt=...) should work""" 34 | signer = signing.Signer("predictable-secret", salt="extra-salt") 35 | self.assertEqual( 36 | signer.signature("hello"), 37 | signing.base64_hmac( 38 | "extra-salt" + "signer", 39 | "hello", 40 | "predictable-secret", 41 | algorithm=signer.algorithm, 42 | ), 43 | ) 44 | self.assertNotEqual( 45 | signing.Signer("predictable-secret", salt="one").signature("hello"), 46 | signing.Signer("predictable-secret", salt="two").signature("hello"), 47 | ) 48 | 49 | def test_custom_algorithm(self): 50 | signer = signing.Signer("predictable-secret", algorithm="sha512") 51 | self.assertEqual( 52 | signer.signature("hello"), 53 | "39g7myx24wdsEj07XSFiTNoGIzdolUgcHk-ynx3nGA8HP-y01_2HLRJIqhNIlkvfb" 54 | "2wKijVMry1wHKIo66TSTw", 55 | ) 56 | 57 | def test_invalid_algorithm(self): 58 | signer = signing.Signer("predictable-secret", algorithm="whatever") 59 | msg = "'whatever' is not an algorithm accepted by the cryptography module." 60 | with self.assertRaisesMessage(InvalidAlgorithm, msg): 61 | signer.sign("hello") 62 | 63 | def test_sign_unsign(self): 64 | """sign/unsign should be reversible""" 65 | signer = signing.Signer("predictable-secret") 66 | examples = [ 67 | "q;wjmbk;wkmb", 68 | "3098247529087", 69 | "3098247:529:087:", 70 | "jkw osanteuh ,rcuh nthu aou oauh ,ud du", 71 | "\u2019", 72 | ] 73 | for example in examples: 74 | signed = signer.sign(example) 75 | self.assertIsInstance(signed, str) 76 | self.assertNotEqual(example, signed) 77 | self.assertEqual(example, signer.unsign(signed)) 78 | 79 | def test_sign_unsign_non_string(self): 80 | signer = signing.Signer("predictable-secret") 81 | values = [ 82 | 123, 83 | 1.23, 84 | True, 85 | datetime.date.today(), 86 | ] 87 | for value in values: 88 | with self.subTest(value): 89 | signed = signer.sign(value) 90 | self.assertIsInstance(signed, str) 91 | self.assertNotEqual(signed, value) 92 | self.assertEqual(signer.unsign(signed), str(value)) 93 | 94 | def test_unsign_detects_tampering(self): 95 | """unsign should raise an exception if the value has been tampered with""" 96 | signer = signing.Signer("predictable-secret") 97 | value = "Another string" 98 | signed_value = signer.sign(value) 99 | transforms = ( 100 | lambda s: s.upper(), 101 | lambda s: s + "a", 102 | lambda s: "a" + s[1:], 103 | lambda s: s.replace(":", ""), 104 | ) 105 | self.assertEqual(value, signer.unsign(signed_value)) 106 | for transform in transforms: 107 | with self.assertRaises(signing.BadSignature): 108 | signer.unsign(transform(signed_value)) 109 | 110 | def test_sign_unsign_object(self): 111 | signer = signing.Signer("predictable-secret") 112 | tests = [ 113 | ["a", "list"], 114 | "a string \u2019", 115 | {"a": "dictionary"}, 116 | ] 117 | for obj in tests: 118 | with self.subTest(obj=obj): 119 | signed_obj = signer.sign_object(obj) 120 | self.assertNotEqual(obj, signed_obj) 121 | self.assertEqual(obj, signer.unsign_object(signed_obj)) 122 | signed_obj = signer.sign_object(obj, compress=True) 123 | self.assertNotEqual(obj, signed_obj) 124 | self.assertEqual(obj, signer.unsign_object(signed_obj)) 125 | 126 | def test_dumps_loads(self): 127 | """dumps and loads be reversible for any JSON serializable object""" 128 | objects = [ 129 | ["a", "list"], 130 | "a string \u2019", 131 | {"a": "dictionary"}, 132 | ] 133 | for o in objects: 134 | self.assertNotEqual(o, signing.dumps(o)) 135 | self.assertEqual(o, signing.loads(signing.dumps(o))) 136 | self.assertNotEqual(o, signing.dumps(o, compress=True)) 137 | self.assertEqual(o, signing.loads(signing.dumps(o, compress=True))) 138 | 139 | def test_decode_detects_tampering(self): 140 | """loads should raise exception for tampered objects""" 141 | transforms = ( 142 | lambda s: s.upper(), 143 | lambda s: s + "a", 144 | lambda s: "a" + s[1:], 145 | lambda s: s.replace(":", ""), 146 | ) 147 | value = { 148 | "foo": "bar", 149 | "baz": 1, 150 | } 151 | encoded = signing.dumps(value) 152 | self.assertEqual(value, signing.loads(encoded)) 153 | for transform in transforms: 154 | with self.assertRaises(signing.BadSignature): 155 | signing.loads(transform(encoded)) 156 | 157 | def test_works_with_non_ascii_keys(self): 158 | binary_key = b"\xe7" # Set some binary (non-ASCII key) 159 | 160 | s = signing.Signer(binary_key) 161 | self.assertEqual( 162 | "foo:fc5zKyRI0Ktcf8db752abovGMa_u2CW9kPCaw5Znhag", 163 | s.sign("foo"), 164 | ) 165 | 166 | def test_valid_sep(self): 167 | separators = ["/", "*sep*", ","] 168 | for sep in separators: 169 | signer = signing.Signer("predictable-secret", sep=sep) 170 | self.assertEqual( 171 | "foo%sLQ8wXoKVFLoLwqvrZsOL9FWEwOy1XDzvduylmAZwNaI" % sep, 172 | signer.sign("foo"), 173 | ) 174 | 175 | def test_invalid_sep(self): 176 | """should warn on invalid separator""" 177 | msg = ( 178 | "Unsafe Signer separator: %r (cannot be empty or consist of only A-z0-9-_=)" 179 | ) 180 | separators = ["", "-", "abc"] 181 | for sep in separators: 182 | with self.assertRaisesMessage(ValueError, msg % sep): 183 | signing.Signer(sep=sep) 184 | 185 | 186 | class TestTimestampSigner(SimpleTestCase): 187 | def test_timestamp_signer(self): 188 | value = "hello" 189 | with freeze_time(123456789): 190 | signer = signing.TimestampSigner("predictable-key") 191 | ts = signer.sign(value) 192 | self.assertNotEqual(ts, signing.Signer("predictable-key").sign(value)) 193 | self.assertEqual(signer.unsign(ts), value) 194 | 195 | with freeze_time(123456800): 196 | self.assertEqual(signer.unsign(ts, max_age=12), value) 197 | # max_age parameter can also accept a datetime.timedelta object 198 | self.assertEqual( 199 | signer.unsign(ts, max_age=datetime.timedelta(seconds=11)), value 200 | ) 201 | with self.assertRaises(signing.SignatureExpired): 202 | signer.unsign(ts, max_age=10) 203 | 204 | 205 | class TestBytesSigner(SimpleTestCase): 206 | def test_signature(self): 207 | """signature() method should generate a signature""" 208 | signer = signing.BytesSigner("predictable-secret") 209 | signer2 = signing.BytesSigner("predictable-secret2") 210 | for s in ( 211 | b"hello", 212 | b"3098247:529:087:", 213 | "\u2019".encode(), 214 | ): 215 | self.assertEqual( 216 | signer.signature(s), 217 | signing.salted_hmac( 218 | signer.salt + "signer", s, "predictable-secret", algorithm="sha256" 219 | ).finalize(), 220 | ) 221 | self.assertNotEqual(signer.signature(s), signer2.signature(s)) 222 | 223 | def test_signature_with_salt(self): 224 | """signature(value, salt=...) should work""" 225 | signer = signing.BytesSigner("predictable-secret", salt="extra-salt") 226 | self.assertEqual( 227 | signer.signature("hello"), 228 | signing.salted_hmac( 229 | "extra-salt" + "signer", 230 | "hello", 231 | "predictable-secret", 232 | algorithm="sha256", 233 | ).finalize(), 234 | ) 235 | self.assertNotEqual( 236 | signing.BytesSigner("predictable-secret", salt="one").signature("hello"), 237 | signing.BytesSigner("predictable-secret", salt="two").signature("hello"), 238 | ) 239 | 240 | def test_sign_unsign(self): 241 | """sign/unsign should be reversible""" 242 | signer = signing.BytesSigner("predictable-secret") 243 | examples = [ 244 | b"q;wjmbk;wkmb", 245 | b"3098247529087", 246 | b"3098247:529:087:", 247 | b"jkw osanteuh ,rcuh nthu aou oauh ,ud du", 248 | rb"\u2019", 249 | ] 250 | for example in examples: 251 | signed = signer.sign(example) 252 | self.assertIsInstance(signed, bytes) 253 | self.assertNotEqual(example, signed) 254 | self.assertEqual(example, signer.unsign(signed)) 255 | 256 | def test_unsign_detects_tampering(self): 257 | """unsign should raise an exception if the value has been tampered with""" 258 | signer = signing.BytesSigner("predictable-secret") 259 | value = b"Another string" 260 | signed_value = signer.sign(value) 261 | transforms = ( 262 | lambda s: s.upper(), 263 | lambda s: s + b"a", 264 | lambda s: b"a" + s[1:], 265 | ) 266 | self.assertEqual(value, signer.unsign(signed_value)) 267 | for transform in transforms: 268 | with self.assertRaises(signing.BadSignature): 269 | signer.unsign(transform(signed_value)) 270 | 271 | def test_dumps_loads(self): 272 | """dumps and loads be reversible for any JSON serializable object""" 273 | objects = [ 274 | ["a", "list"], 275 | "a unicode string \u2019", 276 | {"a": "dictionary"}, 277 | ] 278 | for o in objects: 279 | self.assertNotEqual(o, signing.dumps(o)) 280 | self.assertEqual(o, signing.loads(signing.dumps(o))) 281 | self.assertNotEqual(o, signing.dumps(o, compress=True)) 282 | self.assertEqual(o, signing.loads(signing.dumps(o, compress=True))) 283 | 284 | def test_decode_detects_tampering(self): 285 | """loads should raise exception for tampered objects""" 286 | transforms = ( 287 | lambda s: s.upper(), 288 | lambda s: s + "a", 289 | lambda s: "a" + s[1:], 290 | lambda s: s.replace(":", ""), 291 | ) 292 | value = { 293 | "foo": "bar", 294 | "baz": 1, 295 | } 296 | encoded = signing.dumps(value) 297 | self.assertEqual(value, signing.loads(encoded)) 298 | for transform in transforms: 299 | with self.assertRaises(signing.BadSignature): 300 | signing.loads(transform(encoded)) 301 | 302 | def test_works_with_non_ascii_keys(self): 303 | binary_key = b"\xe7" # Set some binary (non-ASCII key) 304 | 305 | s = signing.BytesSigner(binary_key) 306 | self.assertEqual( 307 | b"foo\xb5\x8a\xc47\x19\xaeN\xdcMT\x83{PAb\r" 308 | b"B\xf3\xd2i\xd1P\x94\xeb^\xc7(\xb4\xd3\x92" 309 | b"\xd3\xf4", 310 | s.sign("foo"), 311 | ) 312 | 313 | 314 | class TestFernetSigner(SimpleTestCase): 315 | def test_fernet_signer(self): 316 | value = b"hello" 317 | with freeze_time(123456789): 318 | signer = signing.FernetSigner("predictable-key") 319 | ts = signer.sign(value, int(time.time())) 320 | self.assertEqual(signer.unsign(ts), value) 321 | 322 | with freeze_time(123456800 + signing._MAX_CLOCK_SKEW): 323 | self.assertEqual(signer.unsign(ts, max_age=12), value) 324 | # max_age parameter can also accept a datetime.timedelta object 325 | self.assertEqual( 326 | signer.unsign(ts, max_age=datetime.timedelta(seconds=11)), value 327 | ) 328 | with self.assertRaises(signing.SignatureExpired): 329 | signer.unsign(ts, max_age=10) 330 | 331 | with freeze_time(123456778 - signing._MAX_CLOCK_SKEW): 332 | with self.assertRaises(signing.SignatureExpired): 333 | signer.unsign(ts, max_age=10) 334 | 335 | def test_bad_payload(self): 336 | signer = signing.FernetSigner("predictable-key") 337 | value = signer.sign("hello", int(time.time())) 338 | 339 | with self.assertRaises(signing.BadSignature): 340 | # Break the version 341 | signer.unsign(b" " + value[1:]) 342 | 343 | with self.assertRaises(signing.BadSignature): 344 | # Break the signature 345 | signer.unsign(value[:-1] + b" ") 346 | 347 | def test_unsupported(self): 348 | value = b"hello" 349 | signer = signing.FernetSigner("predictable-key") 350 | 351 | with self.assertRaises(signing.BadSignature): 352 | signer.unsign(value) 353 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/crypto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgemarshall/django-cryptography/a5cde9beed707a14a2ef2f1f7f1fee172feb8b5e/tests/utils/crypto/__init__.py -------------------------------------------------------------------------------- /tests/utils/crypto/tests.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | import unittest 4 | 5 | from cryptography.hazmat.primitives import hashes 6 | from django.conf import settings 7 | from django.test import SimpleTestCase 8 | from django.test.utils import freeze_time, override_settings 9 | from django.utils.crypto import salted_hmac as django_salted_hmac 10 | 11 | from django_cryptography.core import signing 12 | from django_cryptography.utils.crypto import ( 13 | Fernet, 14 | FernetBytes, 15 | InvalidAlgorithm, 16 | InvalidToken, 17 | constant_time_compare, 18 | pbkdf2, 19 | salted_hmac, 20 | ) 21 | 22 | 23 | class TestUtilsCryptoMisc(SimpleTestCase): 24 | salt = "salted_hmac" 25 | value = "Hello, World!" 26 | 27 | def test_django_hmac_parity(self): 28 | django_hmac = django_salted_hmac(self.salt, self.value) 29 | cryptography_hmac = salted_hmac(self.salt, self.value, algorithm="sha1") 30 | 31 | self.assertEqual(django_hmac.digest(), cryptography_hmac.finalize()) 32 | 33 | def test_constant_time_compare(self): 34 | # It's hard to test for constant time, just test the result. 35 | self.assertTrue(constant_time_compare(b"spam", b"spam")) 36 | self.assertFalse(constant_time_compare(b"spam", b"eggs")) 37 | self.assertTrue(constant_time_compare("spam", "spam")) 38 | self.assertFalse(constant_time_compare("spam", "eggs")) 39 | 40 | def test_salted_hmac(self): 41 | tests = [ 42 | ((b"salt", b"value"), {}, "b51a2e619c43b1ca4f91d15c57455521d71d61eb"), 43 | (("salt", "value"), {}, "b51a2e619c43b1ca4f91d15c57455521d71d61eb"), 44 | ( 45 | ("salt", "value"), 46 | {"secret": "abcdefg"}, 47 | "8bbee04ccddfa24772d1423a0ba43bd0c0e24b76", 48 | ), 49 | ( 50 | ("salt", "value"), 51 | {"secret": "x" * hashes.SHA1.block_size}, 52 | "bd3749347b412b1b0a9ea65220e55767ac8e96b0", 53 | ), 54 | ( 55 | ("salt", "value"), 56 | {"algorithm": "sha256"}, 57 | "ee0bf789e4e009371a5372c90f73fcf17695a8439c9108b0480f14e347b3f9ec", 58 | ), 59 | ( 60 | ("salt", "value"), 61 | { 62 | "algorithm": "blake2b", 63 | "secret": "x" * hashes.BLAKE2b.block_size, 64 | }, 65 | "fc6b9800a584d40732a07fa33fb69c35211269441823bca431a143853c32f" 66 | "e836cf19ab881689528ede647dac412170cd5d3407b44c6d0f44630690c54" 67 | "ad3d58", 68 | ), 69 | ] 70 | for args, kwargs, digest in tests: 71 | with self.subTest(args=args, kwargs=kwargs): 72 | self.assertEqual(salted_hmac(*args, **kwargs).finalize().hex(), digest) 73 | 74 | def test_invalid_algorithm(self): 75 | msg = "'whatever' is not an algorithm accepted by the cryptography module." 76 | with self.assertRaisesMessage(InvalidAlgorithm, msg): 77 | salted_hmac("salt", "value", algorithm="whatever") 78 | 79 | 80 | class TestUtilsCryptoPBKDF2(unittest.TestCase): 81 | # https://tools.ietf.org/html/draft-josefsson-pbkdf2-test-vectors-06 82 | rfc_vectors = [ 83 | { 84 | "args": { 85 | "password": "password", 86 | "salt": "salt", 87 | "iterations": 1, 88 | "dklen": 20, 89 | "digest": hashes.SHA1(), 90 | }, 91 | "result": "0c60c80f961f0e71f3a9b524af6012062fe037a6", 92 | }, 93 | { 94 | "args": { 95 | "password": "password", 96 | "salt": "salt", 97 | "iterations": 2, 98 | "dklen": 20, 99 | "digest": hashes.SHA1(), 100 | }, 101 | "result": "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957", 102 | }, 103 | { 104 | "args": { 105 | "password": "password", 106 | "salt": "salt", 107 | "iterations": 4096, 108 | "dklen": 20, 109 | "digest": hashes.SHA1(), 110 | }, 111 | "result": "4b007901b765489abead49d926f721d065a429c1", 112 | }, 113 | # # this takes way too long :( 114 | # { 115 | # "args": { 116 | # "password": "password", 117 | # "salt": "salt", 118 | # "iterations": 16777216, 119 | # "dklen": 20, 120 | # "digest": hashes.SHA1(), 121 | # }, 122 | # "result": "eefe3d61cd4da4e4e9945b3d6ba2158c2634e984", 123 | # }, 124 | { 125 | "args": { 126 | "password": "passwordPASSWORDpassword", 127 | "salt": "saltSALTsaltSALTsaltSALTsaltSALTsalt", 128 | "iterations": 4096, 129 | "dklen": 25, 130 | "digest": hashes.SHA1(), 131 | }, 132 | "result": "3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038", 133 | }, 134 | { 135 | "args": { 136 | "password": "pass\0word", 137 | "salt": "sa\0lt", 138 | "iterations": 4096, 139 | "dklen": 16, 140 | "digest": hashes.SHA1(), 141 | }, 142 | "result": "56fa6aa75548099dcc37d7f03425e0c3", 143 | }, 144 | ] 145 | 146 | regression_vectors = [ 147 | { 148 | "args": { 149 | "password": "password", 150 | "salt": "salt", 151 | "iterations": 1, 152 | "dklen": 20, 153 | "digest": hashes.SHA256(), 154 | }, 155 | "result": "120fb6cffcf8b32c43e7225256c4f837a86548c9", 156 | }, 157 | { 158 | "args": { 159 | "password": "password", 160 | "salt": "salt", 161 | "iterations": 1, 162 | "dklen": 20, 163 | "digest": hashes.SHA512(), 164 | }, 165 | "result": "867f70cf1ade02cff3752599a3a53dc4af34c7a6", 166 | }, 167 | { 168 | "args": { 169 | "password": "password", 170 | "salt": "salt", 171 | "iterations": 1000, 172 | "dklen": 0, 173 | "digest": hashes.SHA512(), 174 | }, 175 | "result": ( 176 | "afe6c5530785b6cc6b1c6453384731bd5ee432ee" 177 | "549fd42fb6695779ad8a1c5bf59de69c48f774ef" 178 | "c4007d5298f9033c0241d5ab69305e7b64eceeb8d" 179 | "834cfec" 180 | ), 181 | }, 182 | # Check leading zeros are not stripped (#17481) 183 | { 184 | "args": { 185 | "password": b"\xba", 186 | "salt": "salt", 187 | "iterations": 1, 188 | "dklen": 20, 189 | "digest": hashes.SHA1(), 190 | }, 191 | "result": "0053d3b91a7f1e54effebd6d68771e8a6e0b2c5b", 192 | }, 193 | # Check default digest 194 | { 195 | "args": { 196 | "password": "password", 197 | "salt": "salt", 198 | "iterations": 1, 199 | "dklen": 20, 200 | "digest": None, 201 | }, 202 | "result": "120fb6cffcf8b32c43e7225256c4f837a86548c9", 203 | }, 204 | ] 205 | 206 | def test_public_vectors(self): 207 | for vector in self.rfc_vectors: 208 | result = pbkdf2(**vector["args"]) 209 | self.assertEqual(result.hex(), vector["result"]) 210 | 211 | def test_regression_vectors(self): 212 | for vector in self.regression_vectors: 213 | result = pbkdf2(**vector["args"]) 214 | self.assertEqual(result.hex(), vector["result"]) 215 | 216 | def test_default_hmac_alg(self): 217 | kwargs = { 218 | "password": b"password", 219 | "salt": b"salt", 220 | "iterations": 1, 221 | "dklen": 20, 222 | } 223 | self.assertEqual( 224 | pbkdf2(**kwargs), 225 | hashlib.pbkdf2_hmac(hash_name=hashlib.sha256().name, **kwargs), 226 | ) 227 | 228 | 229 | class FernetBytesTestCase(unittest.TestCase): 230 | def test_cryptography_key(self): 231 | self.assertEqual( 232 | settings.CRYPTOGRAPHY_KEY.hex(), 233 | "83c75905b45ce12bb61d2e883896d274c1790473186692519d076de55c49483c", 234 | ) 235 | 236 | def test_encrypt_decrypt(self): 237 | value = b"hello" 238 | iv = b"0123456789abcdef" 239 | data = ( 240 | "8000000000075bcd15303132333435363738396162636465669a7ce822f47" 241 | "33dd8ba87469b264d835c34b2892b06ec88098de6bcb6ca662f5e3240d5c2" 242 | "f5af5728e6198c93a2888b78" 243 | ) 244 | with freeze_time(123456789): 245 | fernet = FernetBytes() 246 | self.assertEqual( 247 | fernet._encrypt_from_parts(value, int(time.time()), iv), 248 | bytes.fromhex(data), 249 | ) 250 | self.assertEqual(fernet.decrypt(bytes.fromhex(data)), value) 251 | 252 | @override_settings(SECRET_KEY="test_key") 253 | def test_decryptor_invalid_token(self): 254 | data = ( 255 | "8000000000075bcd153031323334353637383961626364656629b930b1955" 256 | "ddaec2d74fb4ff565d549d94cc75de940d1d25507f30763f05c412390d15d" 257 | "a26bccee69f1b4543e75" 258 | ) 259 | with freeze_time(123456789): 260 | fernet = FernetBytes() 261 | with self.assertRaises(InvalidToken): 262 | fernet.decrypt(bytes.fromhex(data)) 263 | 264 | @override_settings(SECRET_KEY="test_key") 265 | def test_unpadder_invalid_token(self): 266 | data = ( 267 | "8000000000075bcd15303132333435363738396162636465660ecd40b0f64" 268 | "8f001b78b5a77b334b40fbbff559444b3325233e71c24e53f6028116b0377" 269 | "b910ebe5498396de36dee59b" 270 | ) 271 | with freeze_time(123456789): 272 | fernet = FernetBytes() 273 | with self.assertRaises(InvalidToken): 274 | fernet.decrypt(bytes.fromhex(data)) 275 | 276 | 277 | class StandardFernetTestCase(unittest.TestCase): 278 | def test_encrypt_decrypt(self): 279 | key = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" 280 | value = b"hello" 281 | iv = b"0123456789abcdef" 282 | data = ( 283 | b"gAAAAAAdwJ6wMDEyMzQ1Njc4OWFiY2RlZjYYKxzJY4VTm9YIi4" 284 | b"Pp6o_RvhRbEt-VW6a0zE-ys6tS1_2Xd2011mjXrVrMV0QfRA==" 285 | ) 286 | with freeze_time(499162800): 287 | fernet = Fernet(key) 288 | self.assertEqual( 289 | data, fernet._encrypt_from_parts(value, int(time.time()), iv) 290 | ) 291 | self.assertEqual(value, fernet.decrypt(data, 60)) 292 | 293 | with freeze_time(123456789): 294 | fernet = Fernet(key) 295 | with self.assertRaises(signing.SignatureExpired): 296 | fernet.decrypt(data, 60) 297 | 298 | def test_bad_key(self): 299 | with self.assertRaises(ValueError): 300 | Fernet("") 301 | 302 | def test_default_key(self): 303 | value = b"hello" 304 | iv = b"0123456789abcdef" 305 | data = ( 306 | b"gAAAAAAdwJ6wMDEyMzQ1Njc4OWFiY2RlZpp86CL0cz3YuodGmy" 307 | b"ZNg1zHC5ForoIhr0F33y_CAv2hNHxmx-ZBcM7FK-Fimskaww==" 308 | ) 309 | with freeze_time(499162800): 310 | fernet = Fernet() 311 | self.assertEqual( 312 | data, fernet._encrypt_from_parts(value, int(time.time()), iv) 313 | ) 314 | self.assertEqual(value, fernet.decrypt(data, 60)) 315 | 316 | with freeze_time(123456789): 317 | fernet = Fernet() 318 | with self.assertRaises(signing.SignatureExpired): 319 | fernet.decrypt(data, 60) 320 | 321 | def test_invalid_type(self): 322 | key = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=" 323 | fernet = Fernet(key) 324 | with self.assertRaises(InvalidToken): 325 | fernet.decrypt("Hi") 326 | --------------------------------------------------------------------------------