Metadata-Version: 2.4
Name: shopcloud-django-encrypted-fields
Version: 0.5.0
Summary: Transparently encrypted Django model fields backed by Fernet
Author-email: Talk-Point <developer@talk-point.de>
License-Expression: MIT
Project-URL: Homepage, https://github.com/Talk-Point/shopcloud-django-encrypted-fields
Project-URL: Repository, https://github.com/Talk-Point/shopcloud-django-encrypted-fields.git
Project-URL: Issues, https://github.com/Talk-Point/shopcloud-django-encrypted-fields/issues
Keywords: django,encryption,fernet,field,credentials
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.0
Requires-Dist: cryptography>=42
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-django>=4.5; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: tox>=4.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Dynamic: license-file

# shopcloud-django-encrypted-fields

[![PyPI](https://img.shields.io/pypi/v/shopcloud-django-encrypted-fields)](https://pypi.org/project/shopcloud-django-encrypted-fields/)
[![Python](https://img.shields.io/pypi/pyversions/shopcloud-django-encrypted-fields)](https://pypi.org/project/shopcloud-django-encrypted-fields/)
[![Django](https://img.shields.io/badge/django-5.0%20%7C%205.1%20%7C%205.2%20%7C%206.0%20%7C%206.1-092E20)](https://www.djangoproject.com/)
[![License](https://img.shields.io/pypi/l/shopcloud-django-encrypted-fields)](LICENSE)

Transparently encrypted Django model fields, backed by Fernet (AES-128-CBC + HMAC-SHA256)
from `cryptography`.

The value is encrypted on its way into the database and decrypted on its way out.
Everything in between — forms, DRF serializers, `model_to_dict`, templates — sees the
plaintext, so existing code keeps working unchanged.

```python
from shopcloud_django_encrypted_fields import EncryptedCharField

class Credential(models.Model):
    title = models.CharField(max_length=255)
    password = EncryptedCharField(max_length=255, null=True, blank=True)
```

```python
>>> Credential.objects.create(title="GitHub", password="hunter2").password
'hunter2'
>>> # what the database actually holds:
'gAAAAABm...T7Q=='
```

## Installation

```bash
pip install shopcloud-django-encrypted-fields
```

Generate a key and put it in the environment:

```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```

```python
# settings.py
DJ_ENCRYPTED_FIELDS = {
    "KEYS": [os.environ["ENCRYPTION_KEY"]],   # newest key first
    "READ_PLAINTEXT": True,                   # see "Migrating existing data"
}
```

The app is not added to `INSTALLED_APPS` — there is nothing to install, only fields to import.

## Field types

| Field | Base | Notes |
|---|---|---|
| `EncryptedCharField` | `models.CharField` | `max_length` still validates the plaintext |
| `EncryptedTextField` | `models.TextField` | |
| `EncryptedEmailField` | `models.EmailField` | email validation runs on the plaintext |
| `EncryptedJSONField` | `models.TextField` | stores encrypted JSON; takes `encoder`/`decoder` |
| `EncryptedIntegerField` | `models.IntegerField` | comes back as `int` |
| `EncryptedFloatField` | `models.FloatField` | |
| `EncryptedBooleanField` | `models.BooleanField` | |
| `EncryptedDateField` | `models.DateField` | comes back as `date` |
| `EncryptedDateTimeField` | `models.DateTimeField` | |

Any other text-backed field is three lines:

```python
from shopcloud_django_encrypted_fields import EncryptedMixin

class EncryptedURLField(EncryptedMixin, models.URLField):
    pass
```

## What this protects against

Database dumps, backups in a bucket, direct Cloud SQL access, and anyone reading rows
who is not the application. It does **not** protect against a compromised application
instance: the key is in the app's environment, because the app has to show the value to
the user. That is a deliberate trade-off, not an oversight — genuine zero-knowledge
(key derived from the user's password, crypto in the browser) rules out shared
credentials and SSO, and is a different product.

## Consequences you have to plan for

**Filtering is impossible.** Fernet is non-deterministic — the same plaintext encrypts to a
different token every time — so `filter(password="hunter2")` could never match. Rather than
silently returning an empty queryset, every lookup except `isnull` raises `FieldError`:

```python
>>> Credential.objects.filter(password="hunter2")
FieldError: Lookup 'exact' is not supported on EncryptedCharField — the stored
value is a non-deterministic ciphertext and would never match.
```

If you need to search a value, keep a separate searchable column (a blind index, or a
hash) next to the encrypted one.

**The column becomes TEXT.** A Fernet token is roughly twice the length of its plaintext,
so `varchar(255)` would truncate. `get_internal_type()` returns `"TextField"` for every
encrypted field. `max_length` keeps validating the plaintext in forms.

**A wrong key raises, it never renders.** A value that looks like a Fernet token but
cannot be opened by any configured key raises `DecryptionError` — in every mode,
including while `READ_PLAINTEXT` is on. The alternative would be worse than an error
page: the raw token would reach the view as if it were the secret, and saving that form
would encrypt the ciphertext a second time, destroying the original value. Plaintext
rows are told apart from tokens structurally, so the migration fallback still works.

```python
>>> Credential.objects.get(pk=1).password
DecryptionError: Value is a Fernet token but no configured key decrypts it.
Check DJ_ENCRYPTED_FIELDS['KEYS'] — a retired key has to stay in the list
until everything it wrote has been rotated.
```

A missing or malformed key is reported by `manage.py check` (`encrypted_fields.E001`),
so it fails the deploy rather than the first user who opens a record.

**Input paths never decrypt.** A form, the admin or a DRF serializer treats everything it
receives as plaintext, and refuses a value that is a ciphertext. Decrypting on input would
turn every field someone may edit into an oracle: paste a ciphertext lifted from a
database dump into a record you own, and the plaintext comes back. That would leave the
encrypted column worth nothing against exactly the threat it exists for.

The cost is that `loaddata` cannot read a fixture of encrypted fields back — reading one
requires decrypt-on-input. `dumpdata` still writes ciphertext, so no fixture carries a
secret; move encrypted columns with SQL.

**Never assign a database expression.** `update(field=F("other"))` is compiled straight
to SQL, so no field hook runs and the value lands in the column as plaintext — silently.
Django offers nothing to intercept this from inside a field. Assign the value in Python
and call `save()`; `update()` with a literal and `bulk_create()` both encrypt correctly.

**No database-side uniqueness or ordering.** `unique=True` compares ciphertexts, which are
always different. `order_by` sorts ciphertexts, which is meaningless.

## Migrating existing data

While `READ_PLAINTEXT` is `True` (the default), rows that are not encrypted yet are read
as-is. That makes the switch a normal deploy instead of a maintenance window:

1. Change the field type and run `makemigrations` / `migrate` — this only widens the
   column to TEXT, the data stays untouched.
2. Backfill. Reading gives plaintext, saving writes ciphertext, so this is all it takes:

   ```python
   for obj in Credential.objects.all().iterator():
       obj.save(update_fields=["password"])
   ```

3. Set `READ_PLAINTEXT: False`. From then on an undecryptable value raises instead of
   being handed out as plaintext — which is what you want, because after the backfill it
   means the key is wrong, not that the row is old.

## Key rotation

`KEYS` is a list, newest first. Every key in it can decrypt; only the first one encrypts.
So rotating is: prepend the new key, deploy, re-encrypt, drop the old key.

```python
from shopcloud_django_encrypted_fields import rotate
# re-encrypts a token with the newest key without exposing the plaintext
```

Keep the retired key in the list until the backfill has run — a token it wrote is
unreadable without it.

## Performance

Measured on 2000 rows, SQLite, Python 3.14:

| | |
|---|---|
| encrypt + decrypt, one value | ~14 µs |
| single credential lookup (the common request) | 208 µs total, ~5 % of it crypto |
| loading 2000 rows with decryption | +31 ms over the same query deferred |

For a single credential the crypto is far below the 1–5 ms of one Cloud SQL round trip —
it does not show up in a request. It only becomes visible on list views that load every
row, and there it is avoidable: list serializers usually do not output the secret anyway,
so exclude the column from the query.

```python
queryset = Credential.objects.defer("password", "otp_secret")
```

The Fernet instance is cached on the key tuple, so the key is not rebuilt per value.

## Development

```bash
pip install -e ".[dev]"
pytest tests/
tox            # Python 3.12/3.13 x Django 5.0-6.0
```

## Security

Never report a vulnerability as a public issue — see [SECURITY.md](SECURITY.md), which
also states the threat model and the two properties this package must not regress on.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). A change to the crypto path needs a test for the
failure mode, not only for the happy path.

## License

MIT — Talk-Point GmbH
