dorm.contrib.encrypted¶
Application-level field encryption — stores ciphertext on disk and
decrypts transparently on read. Backed by AES-GCM via the optional
cryptography package (pip install 'djanorm[encrypted]').
Threat model¶
- In scope: a database snapshot leak — stolen backup, hot replica handed to an analyst, misconfigured object-storage ACL. Without the key the column reads as random bytes.
- NOT in scope: a process that has both the running app and the key in memory. Encryption is at rest, not at runtime.
Quick start¶
import dorm
from dorm.contrib.encrypted import EncryptedCharField, EncryptedTextField
class Patient(dorm.Model):
# Equality lookup works (deterministic mode by default).
ssn = EncryptedCharField(max_length=64)
# Random nonce — equality lookup stops working but
# indistinguishability is restored.
notes = EncryptedTextField(deterministic=False)
Generate a key:
Configure once:
Key rotation¶
Set FIELD_ENCRYPTION_KEYS to a list (newest first). Encryption
uses index 0; decryption tries each in order. After enough writes
roll over (or after a manual re-encrypt pass), retire the old key
from the list.
dorm.configure(FIELD_ENCRYPTION_KEYS=[
"<new key>",
"<old key>", # kept until every row has been re-encrypted
])
Field types¶
dorm.contrib.encrypted.EncryptedCharField
¶
Bases: EncryptedFieldMixin, CharField
CharField that stores ciphertext on disk.
The column type is the same VARCHAR(N) CharField would
emit; ciphertext expands by ~33% (base64 of nonce+ct+tag) so
pick max_length ≈ plaintext_max * 2 to stay safe.
dorm.contrib.encrypted.EncryptedTextField
¶
Bases: EncryptedFieldMixin, TextField
TextField variant — no length cap, suitable for blobs of
arbitrary size (notes, addresses, JSON-as-text).
dorm.contrib.encrypted.EncryptedFieldMixin
¶
Mixin that wraps :meth:get_prep_value / :meth:from_db_value
around AES-GCM. Compose with CharField or TextField so
the underlying column type / max_length stay configurable.
When to pick which mode¶
| Mode | Equality lookup | Indistinguishability | Use case |
|---|---|---|---|
deterministic=True |
✅ works | ❌ same plaintext → same ciphertext | filter-by-value (email, SSN) |
deterministic=False |
❌ broken | ✅ random nonce per write | bulk text where lookups don't matter |
Range / substring / sort lookups will NEVER work — the ciphertext doesn't preserve those orderings on either mode. Use a separate plaintext search-helper column when you need them.
Tampering protection¶
Every blob carries the AES-GCM authentication tag. Tampered
ciphertext raises ValueError("could not decrypt") rather than
silently returning None — better to surface the bug than mask
it. A blob written under a key not in the active rotation list
fails the same way.