Skip to content

Commit 80d7631

Browse files
committed
Release 0.3.2
1 parent fdde3b3 commit 80d7631

23 files changed

+545
-64
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.3.1
2+
current_version = 0.3.2
33
commit = True
44
tag = True
55

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Performance benchmarks section
1414
- Security best practices guide
1515

16+
## [0.3.2] - 2025-12-27
17+
18+
### Added
19+
- Encrypted counterparts for common Django fields (JSON, UUID, Decimal, Boolean, URL, Slug, Float, PositiveInteger)
20+
- Deterministic UUID and Boolean field support
21+
- Example Django integration project and extended tests for new fields
22+
23+
### Changed
24+
- JSON field encryption now preserves structured payloads on round-trip
25+
- Improved README clarity around easy Django field encryption
26+
27+
## [0.3.1] - 2025-12-27
28+
29+
### Added
30+
- Cached keyset handles to reduce repeated keyset file reads
31+
- Example Django project with integration tests covering ciphertext and tamper detection
32+
33+
### Changed
34+
- Registered Tink primitives during direct field module imports
35+
- Prepared deterministic lookup values before encryption for better consistency
36+
- Updated dependency minimums to current releases
37+
38+
### Removed
39+
- Retired legacy CHANGES.md in favor of this changelog
40+
1641
## [0.3.0] - 2024-12-19
1742

1843
### Added

CHANGES.md

Lines changed: 0 additions & 11 deletions
This file was deleted.

README.md

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
88
[![Tests](https://github.com/script3r/django-tink-fields/workflows/Tests/badge.svg)](https://github.com/script3r/django-tink-fields/actions)
99

10-
**Django Tink Fields** provides encrypted Django model fields using [Google Tink](https://developers.google.com/tink) cryptographic library. This package offers field-level encryption for Django models with strong security guarantees and easy integration.
10+
**Django Tink Fields** is a simple, production-ready way to encrypt Django model fields using the [Google Tink](https://developers.google.com/tink) cryptographic library. It offers drop-in encrypted field types for Django models, so you can protect sensitive data with minimal code changes.
11+
12+
Keywords: Django field encryption, encrypted model fields, Google Tink, AEAD, deterministic encryption.
1113

1214
## ✨ Features
1315

@@ -75,9 +77,31 @@ class UserProfile(models.Model):
7577
| `EncryptedCharField` | `CharField` | Encrypted character field |
7678
| `EncryptedTextField` | `TextField` | Encrypted text field |
7779
| `EncryptedEmailField` | `EmailField` | Encrypted email field |
80+
| `EncryptedBooleanField` | `BooleanField` | Encrypted boolean field |
7881
| `EncryptedIntegerField` | `IntegerField` | Encrypted integer field |
82+
| `EncryptedPositiveIntegerField` | `PositiveIntegerField` | Encrypted positive integer field |
83+
| `EncryptedFloatField` | `FloatField` | Encrypted float field |
84+
| `EncryptedDecimalField` | `DecimalField` | Encrypted decimal field |
85+
| `EncryptedUUIDField` | `UUIDField` | Encrypted UUID field |
86+
| `EncryptedJSONField` | `JSONField` | Encrypted JSON field |
87+
| `EncryptedURLField` | `URLField` | Encrypted URL field |
88+
| `EncryptedSlugField` | `SlugField` | Encrypted slug field |
7989
| `EncryptedDateField` | `DateField` | Encrypted date field |
8090
| `EncryptedDateTimeField` | `DateTimeField` | Encrypted datetime field |
91+
| `EncryptedBinaryField` | `BinaryField` | Encrypted binary field |
92+
93+
### Deterministic Field Types
94+
95+
| Field Type | Django Equivalent | Description |
96+
|------------|-------------------|-------------|
97+
| `DeterministicEncryptedTextField` | `TextField` | Deterministic encrypted text field |
98+
| `DeterministicEncryptedCharField` | `CharField` | Deterministic encrypted character field |
99+
| `DeterministicEncryptedEmailField` | `EmailField` | Deterministic encrypted email field |
100+
| `DeterministicEncryptedIntegerField` | `IntegerField` | Deterministic encrypted integer field |
101+
| `DeterministicEncryptedUUIDField` | `UUIDField` | Deterministic encrypted UUID field |
102+
| `DeterministicEncryptedBooleanField` | `BooleanField` | Deterministic encrypted boolean field |
103+
| `DeterministicEncryptedDateField` | `DateField` | Deterministic encrypted date field |
104+
| `DeterministicEncryptedDateTimeField` | `DateTimeField` | Deterministic encrypted datetime field |
81105

82106
### Configuration Options
83107

@@ -234,6 +258,16 @@ pytest tink_fields/test/test_fields.py # Basic functionality
234258
pytest tink_fields/test/test_coverage.py # Edge cases
235259
```
236260

261+
### Integration Test Harness
262+
263+
This repo ships a minimal Django project under `example_project/` that exercises
264+
real model usage and verifies ciphertext at rest, tamper detection, deterministic
265+
lookups, and AAD behavior:
266+
267+
```bash
268+
pytest -c example_project/pytest.ini example_project/example_app/tests
269+
```
270+
237271
## 🛠️ Development
238272

239273
### Setup Development Environment
@@ -307,7 +341,7 @@ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) f
307341

308342
## 📝 Changelog
309343

310-
### v0.3.0 (Latest)
344+
### v0.3.2 (Latest)
311345
- ✨ Modernized codebase with Python 3.10+ support
312346
- 🔧 Updated dependencies to latest versions
313347
- 📊 Improved test coverage to 97%+
@@ -320,7 +354,7 @@ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) f
320354

321355
## 📄 License
322356

323-
This project is licensed under the BSD License - see the [LICENSE](LICENSE) file for details.
357+
This project is licensed under the BSD License - see the [LICENSE.txt](LICENSE.txt) file for details.
324358

325359
## 🙏 Acknowledgments
326360

@@ -336,4 +370,4 @@ This project is licensed under the BSD License - see the [LICENSE](LICENSE) file
336370

337371
---
338372

339-
**Made with ❤️ for the Django community**
373+
**Made with ❤️ for the Django community**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.1
1+
0.3.2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Example app for integration testing."""
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ExampleAppConfig(AppConfig):
5+
default_auto_field = "django.db.models.AutoField"
6+
name = "example_app"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from django.db import models
2+
from django.utils.encoding import force_bytes
3+
4+
from tink_fields import (
5+
DeterministicEncryptedEmailField,
6+
DeterministicEncryptedIntegerField,
7+
DeterministicEncryptedTextField,
8+
DeterministicEncryptedUUIDField,
9+
EncryptedBooleanField,
10+
EncryptedBinaryField,
11+
EncryptedCharField,
12+
EncryptedDateField,
13+
EncryptedDateTimeField,
14+
EncryptedDecimalField,
15+
EncryptedEmailField,
16+
EncryptedFloatField,
17+
EncryptedIntegerField,
18+
EncryptedJSONField,
19+
EncryptedTextField,
20+
EncryptedURLField,
21+
EncryptedUUIDField,
22+
)
23+
24+
25+
def aad_for_field(field: models.Field) -> bytes:
26+
return force_bytes(f"{field.model._meta.label}:{field.name}")
27+
28+
29+
class EncryptedSample(models.Model):
30+
name = EncryptedCharField(max_length=50)
31+
bio = EncryptedTextField()
32+
email = EncryptedEmailField()
33+
count = EncryptedIntegerField()
34+
birth_date = EncryptedDateField()
35+
created_at = EncryptedDateTimeField()
36+
payload = EncryptedBinaryField(null=True)
37+
38+
39+
class EncryptedWithAad(models.Model):
40+
secret = EncryptedCharField(max_length=50, aad_callback=aad_for_field)
41+
42+
43+
class DeterministicSample(models.Model):
44+
keyword = DeterministicEncryptedTextField(keyset="deterministic")
45+
email = DeterministicEncryptedEmailField(keyset="deterministic")
46+
count = DeterministicEncryptedIntegerField(keyset="deterministic")
47+
48+
49+
class ExtendedEncryptedSample(models.Model):
50+
flag = EncryptedBooleanField()
51+
ratio = EncryptedFloatField()
52+
amount = EncryptedDecimalField(max_digits=8, decimal_places=2)
53+
token = EncryptedUUIDField()
54+
payload = EncryptedJSONField()
55+
url = EncryptedURLField()
56+
57+
58+
class DeterministicExtendedSample(models.Model):
59+
token = DeterministicEncryptedUUIDField(keyset="deterministic")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Integration tests for the example Django project."""
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from datetime import date, datetime
2+
from decimal import Decimal
3+
from uuid import uuid4
4+
5+
from django.db import connection
6+
from django.utils.encoding import force_bytes
7+
8+
import pytest
9+
10+
from example_app import models
11+
12+
13+
def _fetch_raw_bytes(model_cls, field_name, pk):
14+
with connection.cursor() as cursor:
15+
cursor.execute(
16+
f"SELECT {field_name} FROM {model_cls._meta.db_table} WHERE id = %s",
17+
[pk],
18+
)
19+
value = cursor.fetchone()[0]
20+
return bytes(value)
21+
22+
23+
def _update_raw_bytes(model_cls, field_name, pk, raw_bytes):
24+
with connection.cursor() as cursor:
25+
cursor.execute(
26+
f"UPDATE {model_cls._meta.db_table} SET {field_name} = %s WHERE id = %s",
27+
[raw_bytes, pk],
28+
)
29+
30+
31+
@pytest.mark.django_db
32+
def test_round_trip_and_ciphertext_at_rest():
33+
payload = b"binary payload \x00\x01\x02"
34+
instance = models.EncryptedSample.objects.create(
35+
name="Alice",
36+
bio="Encrypted bio",
37+
38+
count=42,
39+
birth_date=date(1990, 1, 1),
40+
created_at=datetime(2024, 1, 1, 12, 0, 0),
41+
payload=payload,
42+
)
43+
instance.refresh_from_db()
44+
45+
assert instance.name == "Alice"
46+
assert instance.bio == "Encrypted bio"
47+
assert instance.email == "[email protected]"
48+
assert instance.count == 42
49+
assert instance.birth_date == date(1990, 1, 1)
50+
assert instance.created_at == datetime(2024, 1, 1, 12, 0, 0)
51+
assert instance.payload == payload
52+
53+
raw_name = _fetch_raw_bytes(models.EncryptedSample, "name", instance.id)
54+
raw_payload = _fetch_raw_bytes(models.EncryptedSample, "payload", instance.id)
55+
assert raw_name != force_bytes("Alice")
56+
assert raw_payload != payload
57+
58+
59+
@pytest.mark.django_db
60+
def test_tamper_detection():
61+
instance = models.EncryptedSample.objects.create(
62+
name="Tamper",
63+
bio="Test",
64+
65+
count=7,
66+
birth_date=date(2000, 2, 2),
67+
created_at=datetime(2024, 2, 2, 10, 0, 0),
68+
)
69+
raw_name = _fetch_raw_bytes(models.EncryptedSample, "name", instance.id)
70+
tampered = bytearray(raw_name)
71+
tampered[0] = (tampered[0] + 1) % 256
72+
_update_raw_bytes(models.EncryptedSample, "name", instance.id, bytes(tampered))
73+
74+
with pytest.raises(Exception):
75+
instance.refresh_from_db()
76+
77+
78+
@pytest.mark.django_db
79+
def test_deterministic_ciphertext_and_lookup():
80+
first = models.DeterministicSample.objects.create(
81+
keyword="repeat",
82+
83+
count=5,
84+
)
85+
second = models.DeterministicSample.objects.create(
86+
keyword="repeat",
87+
88+
count=5,
89+
)
90+
raw_first = _fetch_raw_bytes(models.DeterministicSample, "keyword", first.id)
91+
raw_second = _fetch_raw_bytes(models.DeterministicSample, "keyword", second.id)
92+
assert raw_first == raw_second
93+
94+
matches = models.DeterministicSample.objects.filter(keyword="repeat")
95+
assert matches.count() == 2
96+
97+
98+
@pytest.mark.django_db
99+
def test_aad_enforced():
100+
instance = models.EncryptedWithAad.objects.create(secret="aad-secret")
101+
raw_secret = _fetch_raw_bytes(models.EncryptedWithAad, "secret", instance.id)
102+
103+
field = models.EncryptedWithAad._meta.get_field("secret")
104+
with pytest.raises(Exception):
105+
field._keyset_manager.aead_primitive.decrypt(raw_secret, b"wrong-aad")
106+
107+
108+
@pytest.mark.django_db
109+
def test_extended_fields_round_trip():
110+
token = uuid4()
111+
payload = {"alpha": 1, "beta": {"gamma": "delta"}, "items": [1, 2, 3]}
112+
instance = models.ExtendedEncryptedSample.objects.create(
113+
flag=True,
114+
ratio=3.14,
115+
amount=Decimal("1234.50"),
116+
token=token,
117+
payload=payload,
118+
url="https://example.com/alpha",
119+
)
120+
instance.refresh_from_db()
121+
122+
assert instance.flag is True
123+
assert instance.ratio == 3.14
124+
assert instance.amount == Decimal("1234.50")
125+
assert instance.token == token
126+
assert instance.payload == payload
127+
assert instance.url == "https://example.com/alpha"
128+
129+
raw_payload = _fetch_raw_bytes(models.ExtendedEncryptedSample, "payload", instance.id)
130+
assert raw_payload != force_bytes(payload)
131+
132+
133+
@pytest.mark.django_db
134+
def test_deterministic_uuid_ciphertext():
135+
token = uuid4()
136+
first = models.DeterministicExtendedSample.objects.create(token=token)
137+
second = models.DeterministicExtendedSample.objects.create(token=token)
138+
139+
raw_first = _fetch_raw_bytes(models.DeterministicExtendedSample, "token", first.id)
140+
raw_second = _fetch_raw_bytes(models.DeterministicExtendedSample, "token", second.id)
141+
assert raw_first == raw_second

0 commit comments

Comments
 (0)