diff --git a/CHANGELOG.md b/CHANGELOG.md index fa53e10..171816c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Load existing JSON keysets through Tink's explicit `json_proto_keyset_format` APIs, with typed handles and unchanged encrypted-keyset AAD. -- Exercise all advertised Python/Django combinations and the minimum supported Tink 1.13.0 in CI and tox. - -- Share AEAD and deterministic AEAD primitives across fields using the same cached keyset, avoiding repeated wrapper construction while retaining bounded caching and weak manager tracking. +## [0.5.0] - 2026-09-05 ### Fixed -- Expand user-relative keyset paths before validation and report invalid paths, non-UTF-8 keysets, invalid master primitives, and incompatible AEAD keysets as configuration errors. +- Reject inherited JSON/date transforms and late-registered plaintext lookups on encrypted columns; preserve deterministic exact and SQL null lookups. +- Restore the database timezone when decrypting naive datetime representations under `USE_TZ=True`, preventing new read/re-save shifts without changing ciphertext serialization. +- Encrypt binary buffer contents before driver adaptation, fixing PostgreSQL writes that encrypted the string representation of a `psycopg.Binary` adapter. +- Synchronize primitive construction/publication with `clear_keyset_cache()` so an in-flight load cannot republish a stale primitive after invalidation returns. +- Validate resolved positional and keyword database options; default randomized slug fields to `db_index=False`. +- Expand user-relative keyset paths before validation and report invalid paths, non-UTF-8 files, invalid master primitives, and incompatible AEAD keysets as configuration errors. + +### Changed -- Validate positional database options after Django resolves them. Randomized slug fields now default to `db_index=False`; existing applications should generate and apply the resulting index-removal migration. +- Share AEAD/DAEAD primitives across fields using the same cached keyset, reducing repeated construction while retaining weak manager tracking and a bounded shared lookup cache. +- Load existing JSON keysets with Tink's `json_proto_keyset_format` APIs and typed handles, retaining empty encrypted-keyset AAD. +- Document explicit model validation, actual backend test coverage, deterministic representation/rotation limits, and historical data recovery. Add operations, upgrade, and example-project guides. -- Encrypt binary buffer contents before driver adaptation, fixing PostgreSQL writes that encrypted the string representation of a `psycopg.Binary` adapter. Previously corrupted values require application-specific recovery; this fix does not rewrite stored rows. +### Added -- Synchronize primitive construction with cache invalidation so an in-flight load cannot republish a stale primitive after `clear_keyset_cache()`. +- Coverage for all eight advertised Python/Django combinations and a separate Tink 1.13.0 environment in CI and tox. Runtime dependency bounds are unchanged. +- Behavioral regression tests for lookups, timezone round trips, psycopg adaptation, field options, concurrent invalidation, rotation, and configuration errors; 137 library and 6 example tests at release preparation. +- A reproducible cache benchmark showing reduced initialization work and the small warm-call cost of synchronization. -- Restore the database timezone when decrypting naive datetime representations under `USE_TZ=True`, preserving instants across reads, re-saves, and deterministic lookups without rewriting stored ciphertext. +### Upgrade notes -- Reject inherited JSON/date transforms and late-registered plaintext lookups on encrypted columns; keep deterministic exact and SQL null lookups explicit. +- Inspect existing encrypted slug indexes. Historical migrations may omit the former default, so `makemigrations` can report no changes while an old index remains; a reviewed database-specific removal migration may be needed. +- Already-corrupted PostgreSQL binary values and previously shifted datetimes require application-specific recovery. Correctly stored ciphertext remains readable; this release does not rewrite data automatically. +- Configurations and queries that relied on validation/lookup bypasses now fail early. Deterministic key rotation still requires a coordinated migration for equality and plaintext uniqueness. +- Read the [0.5.0 upgrade guide](docs/upgrading-to-0.5.md) before deployment. ## [0.4.0] - 2026-08-01 @@ -145,11 +154,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Migration Guide +### Upgrading to 0.5.0 + +See the [0.5.0 upgrade guide](docs/upgrading-to-0.5.md) for required index inspection, stricter field/query restrictions, and historical data recovery limits. + ### Upgrading from 0.3.x to 0.4.0 - Run `makemigrations --check`. Models using non-default `keyset` or `aad_callback` options may now produce a corrective `AlterField` migration because older releases omitted those options from migration state. -- Existing ciphertext and keyset files remain compatible. Keep every old decryption key enabled when rotating a Tink keyset. -- Randomized fields still reject indexes and uniqueness. Deterministic fields now accept them, but adding either requires a normal schema migration and explicit acceptance of deterministic encryption's equality leakage. +- Existing ciphertext and keyset files remain compatible. Keeping old keys enabled preserves decryption during rotation, but deterministic exact lookups and plaintext uniqueness do not automatically span key generations; see the [operations guide](docs/operations.md#rotation-and-data-migration). +- Randomized field definitions are intended to reject indexes and uniqueness, but 0.4.0 still has positional-argument and implicit slug-index bypasses fixed in 0.5.0. Deterministic fields accept indexes and uniqueness, subject to schema migration and equality-leakage tradeoffs. - Configuration errors now occur on the first encrypt/decrypt operation rather than during model import. - Python 3.10/3.11 and Django 5.2 are supported again; Django 6.0 still requires Python 3.12 or newer. @@ -190,7 +203,7 @@ No breaking changes to configuration, but consider: ## Security Advisories ### 2025-09-13 -- **Dependency Updates**: Updated all dependencies to latest secure versions +- **Dependency Updates**: Dependency versions were updated; this historical entry is not a guarantee that those versions remain vulnerability-free. - **Key Management**: Enhanced keyset validation and error handling - **Encryption**: No changes to encryption algorithms or security model @@ -215,7 +228,8 @@ Thank you to all contributors who have helped improve Django Tink Fields! - [Documentation](https://github.com/script3r/django-tink-fields#readme) - [Issue Tracker](https://github.com/script3r/django-tink-fields/issues) -[Unreleased]: https://github.com/script3r/django-tink-fields/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/script3r/django-tink-fields/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/script3r/django-tink-fields/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/script3r/django-tink-fields/compare/v0.3.2...v0.4.0 [0.3.2]: https://github.com/script3r/django-tink-fields/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/script3r/django-tink-fields/compare/v0.2.0...v0.3.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2937ec..023b735 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,11 @@ # Contributing -Contributions are welcome. For security-sensitive reports, follow [SECURITY.md](SECURITY.md) instead of opening a public issue. +For suspected vulnerabilities, follow [SECURITY.md](SECURITY.md). Use synthetic +data and test-only keys in public issues and pull requests. ## Setup -Use any supported Python version and install the project in an isolated environment: +Use a supported Python version in a repository checkout: ```bash python -m venv .venv @@ -13,9 +14,12 @@ python -m pip install --upgrade pip python -m pip install -e ".[dev,test]" build twine pip-audit bandit tox ``` -## Checks +Python 3.10/3.11 use Django 5.2; Python 3.12–3.14 support Django 5.2 and 6.0. +The test extra installs psycopg for driver-adaptation tests, but no PostgreSQL +server is needed. Tests and fixture keysets are excluded from release packages; +run the suite from a checkout, not an unpacked wheel or source distribution. -Run the same checks used by CI: +## Checks ```bash python -m pytest @@ -23,16 +27,39 @@ python -m pytest -c example_project/pytest.ini example_project/example_app/tests ruff check . ruff format --check . pyright --pythonpath "$(command -v python)" -python -m build -twine check --strict dist/* +pip-audit +bandit -r tink_fields -x tink_fields/test -ll +python -m build --outdir /tmp/django-tink-fields-build +twine check --strict /tmp/django-tink-fields-build/* ``` -Run `tox` to exercise the supported Python and Django combinations available on your machine. +Use a fresh output directory for each package validation. Run `tox` for the eight +supported Python/Django combinations and the additional Python 3.10 / Django 5.2 +/ Tink 1.13.0 environment. Install all five interpreters for a complete local +matrix. CI runs the same nine test environments plus a quality job. + +Bug fixes should include a regression that fails before the fix. Changes to +persistence, lookups, AAD, or migration serialization should verify Python values +and raw database behavior. Include legacy ciphertext compatibility where +relevant. Coverage is a regression signal, not evidence that every backend or +security property has been tested. + +Use `ruff format .` and `ruff check . --fix` for mechanical changes. Keep benchmarks +reproducible and report setup, sample method, and regressions as well as gains; +`python -m benchmarks.keyset_cache` is the current cache benchmark. -Bug fixes should include a regression test. Changes to field persistence, lookup preparation, AAD, or migration serialization should test both the Python round trip and raw database behavior. Never commit real key material; the repository keysets are test-only fixtures. +## Documentation and pull requests -Use `ruff format .` and `ruff check . --fix` for mechanical formatting and safe lint fixes. Keep public behavior and security tradeoffs documented in the README and changelog. +Keep the README, [operations guide](docs/operations.md), upgrade guidance, and +changelog consistent with the public behavior. Check examples against the actual +API. Distinguish SQLite integration, psycopg driver tests, and untested database +server behavior. Preserve historical migration import paths when moving classes. -## Pull requests +Describe the concrete behavior change, validation, compatibility limits, and any +data/schema migration. Keep unrelated fixes separate. For stacked PRs, verify the +base before every merge: a PR marked merged into another feature branch is not +necessarily in `main`. Retarget the next PR after its parent lands, and verify +commit ancestry before releasing. Squash/rebase merges may require rebuilding +the remaining stack. -Describe the user-visible behavior, compatibility implications, and commands used to verify the change. Keep unrelated changes separate. A maintainer will review cryptographic API changes, data migration implications, and release notes particularly carefully. +See [RELEASING.md](RELEASING.md) for versioning, package validation, and publication. diff --git a/MANIFEST.in b/MANIFEST.in index 375e8cc..f2cd5e7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,4 +5,6 @@ include LICENSE.txt include README.md include RELEASING.md include SECURITY.md +recursive-include docs *.md +include example_project/README.md prune tink_fields/test diff --git a/README.md b/README.md index 8e5f652..3958313 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,13 @@ Encrypted Django model fields backed by [Google Tink](https://developers.google. | 3.12 | 5.2, 6.0 | | 3.13, 3.14 | 5.2, 6.0 | -The package is tested against SQLite. The fields use Django's `BinaryField` database type and are intended to work on every database supported by Django, but applications should run their own backend-specific integration tests. +Runtime dependencies are `Django>=5.2,<6.1` and `tink>=1.13,<2`. CI covers all eight Python/Django combinations above and separately tests Tink 1.13.0. + +SQLite has full ORM integration coverage. PostgreSQL tests use the real psycopg driver adapter without a server; PostgreSQL, MySQL, and Oracle server compatibility is not established by this suite. Encrypted columns use Django's `BinaryField` database type, but backend-dependent serialization still affects some values and deterministic equality. See the [operations guide](https://github.com/script3r/django-tink-fields/blob/main/docs/operations.md). + +## Upgrading to 0.5.0 + +Read the [upgrade guide](https://github.com/script3r/django-tink-fields/blob/main/docs/upgrading-to-0.5.md) before deploying. Check existing slug indexes even if `makemigrations` reports no changes, review rejected field/query configurations, and assess PostgreSQL binary values or datetimes already corrupted by earlier writes. This release prevents new failures; it cannot reconstruct lost or previously shifted data automatically. ## Installation @@ -56,7 +62,7 @@ TINK_FIELDS_CONFIG = { } ``` -Configuration and key files are loaded lazily, when a field first encrypts or decrypts a value. This allows Django to import models and serialize migrations in environments that do not hold production keys. +Configuration and key files are loaded lazily, when a field first encrypts or decrypts a value. This allows Django to import models and serialize migrations in environments that do not hold production keys. `path` accepts strings or path-like objects and expands `~`; `cleartext` must be a Boolean and defaults to `False`. Encrypted keyset loading uses empty keyset-encryption AAD. A KMS client and credentials must be configured by the application; a URI or raw master key is not an `Aead` instance. ## Usage @@ -71,7 +77,7 @@ class Customer(models.Model): birth_date = EncryptedDateField(null=True) ``` -Values are ordinary Python objects on model instances. The fields use the corresponding built-in field's validators, encrypt values before database storage, and decrypt them when loading rows. As with ordinary Django models, `save()` does not call `full_clean()` automatically; use a validated model form or call `full_clean()` explicitly when validation is required. +On reads, field values are decrypted into the corresponding Python types. The fields use the corresponding built-in field's validators, encrypt values before database storage, and decrypt them when loading rows. As with ordinary Django models, `save()` does not call `full_clean()` automatically; use a validated model form or call `full_clean()` explicitly when validation is required. ### Randomized fields @@ -93,7 +99,9 @@ Values are ordinary Python objects on model instances. The fields use the corres | `EncryptedURLField` | `URLField` | | `EncryptedUUIDField` | `UUIDField` | -Randomized fields deliberately reject `primary_key`, `unique`, `db_index`, and `db_default`. They support `isnull` queries, including the equivalent `field=None`; every lookup that compares values raises `FieldError`. `EncryptedSlugField` defaults to `db_index=False`, unlike Django's plaintext slug field. Applications upgrading from an earlier version should run `makemigrations` and review the generated index-removal migration. Inherited JSON key lookups, date transforms, and custom registered lookups are also rejected because they would operate on ciphertext. Database expressions such as `F()` assignments are also rejected because the database cannot encrypt them. +Randomized fields reject field-level `primary_key`, `unique`, `db_index`, and `db_default`, whether supplied positionally or by keyword. They support `isnull` queries, including `field=None`; value comparisons raise `FieldError`. `EncryptedSlugField` defaults to `db_index=False`. Older migrations may omit the former default, so removing an old physical index can require an explicit database migration even when `makemigrations` reports no changes. + +JSON key lookups/projections, date transforms, and custom registered lookups are rejected. Database expressions such as `F()` assignments and the `CASE` expressions used by `bulk_update()` are unsupported. Literal saves, `bulk_create()`, and literal `QuerySet.update()` encrypt through the field; validation remains explicit. ### Deterministic fields and exact lookups @@ -114,6 +122,7 @@ TINK_FIELDS_CONFIG = { ``` ```python +from django.db import models from tink_fields import DeterministicEncryptedCharField @@ -126,10 +135,13 @@ class ExternalIdentity(models.Model): ) +ExternalIdentity.objects.create(subject="stable-external-id") identity = ExternalIdentity.objects.get(subject="stable-external-id") ``` -Available deterministic types are `Text`, `Char`, `Email`, `Integer`, `UUID`, `Boolean`, `Date`, and `DateTime`. They support only `exact` and `isnull` lookups. `db_index` and `unique` are supported; primary keys and database defaults are not. +Available deterministic types are `DeterministicEncryptedTextField`, `DeterministicEncryptedCharField`, `DeterministicEncryptedEmailField`, `DeterministicEncryptedIntegerField`, `DeterministicEncryptedUUIDField`, `DeterministicEncryptedBooleanField`, `DeterministicEncryptedDateField`, and `DeterministicEncryptedDateTimeField`. + +They support only `exact` and `isnull` lookups, including `field=None`. `db_index` and `unique` are supported; primary keys and database defaults are not. Equality and uniqueness compare ciphertext from the same prepared bytes, key generation, and AAD. They do not provide plaintext collation, case folding, or equality across differently normalized values. For example, PostgreSQL datetime values representing the same instant with different offsets can serialize differently; review the [representation limits](https://github.com/script3r/django-tink-fields/blob/main/docs/operations.md#database-behavior). Deterministic encryption reveals when rows contain equal values, which can expose frequency and membership information. Do not use it for low-entropy secrets such as Boolean values, status codes, or predictable identifiers unless that leakage is explicitly acceptable. An index makes equality patterns still easier to observe. @@ -155,7 +167,7 @@ class Credential(models.Model): ) ``` -The callback receives the Django field, not the model instance. It must return the same bytes for every future read of existing ciphertext. Keep it at module scope so Django migrations can serialize it. Renaming a model or field will make context-derived AAD change, so plan a data migration before such a rename. +The default AAD is empty and does not bind ciphertext to a row, tenant, field, or model. The callback receives the Django field, not the model instance; it cannot implement row-specific context. It must return the same bytes for every future read of existing ciphertext. Keep it at module scope so Django migrations can serialize it. Renaming a model or field will make context-derived AAD change, so plan a data migration before such a rename. ## Key rotation and data migrations @@ -178,29 +190,21 @@ Changing `keyset=` does not re-encrypt existing rows; it only changes how future - Losing the keyset or required master key makes data unrecoverable. - Exposing a cleartext keyset exposes every value encrypted with it. - Encryption does not hide row existence, nullness, ciphertext length, access patterns, or—when deterministic encryption is used—equality patterns. -- Ordering encrypted columns is permitted by databases but orders ciphertext, not plaintext, and has no useful application meaning. +- Ordering, explicit SQL functions, aggregates, and custom database constraints can operate on ciphertext; the lookup restrictions are not a SQL sandbox and do not give these operations plaintext semantics. - AAD authenticates context but is not secret and is not stored automatically. - Field validation requires a model form or an explicit `full_clean()` call; encryption is not a substitute for application validation, authorization, logging controls, backups, or database security. -See [SECURITY.md](SECURITY.md) for vulnerability reporting and supported releases. - -## Development +See the [security policy](https://github.com/script3r/django-tink-fields/blob/main/SECURITY.md) for vulnerability reporting and supported releases. -```bash -python -m venv .venv -source .venv/bin/activate -python -m pip install -e ".[dev,test]" build twine pip-audit bandit tox - -python -m pytest -python -m pytest -c example_project/pytest.ini example_project/example_app/tests -ruff check . -ruff format --check . -pyright --pythonpath "$(command -v python)" -tox -``` +## Documentation and development -The release process is documented in [RELEASING.md](RELEASING.md). Changes are recorded in [CHANGELOG.md](CHANGELOG.md). +- [Operations and limitations](https://github.com/script3r/django-tink-fields/blob/main/docs/operations.md): queries, validation, AAD, caching, rotation, and backend behavior. +- [0.5.0 upgrade guide](https://github.com/script3r/django-tink-fields/blob/main/docs/upgrading-to-0.5.md): schema checks, compatibility changes, and historical data recovery. +- [Contributing](https://github.com/script3r/django-tink-fields/blob/main/CONTRIBUTING.md): setup, the nine-environment test matrix, lint/type checks, audits, and builds. +- [Example project](https://github.com/script3r/django-tink-fields/blob/main/example_project/README.md): a SQLite test harness using disposable fixture keys. +- [Release process](https://github.com/script3r/django-tink-fields/blob/main/RELEASING.md) and [changelog](https://github.com/script3r/django-tink-fields/blob/main/CHANGELOG.md). +- [September 2026 review](https://github.com/script3r/django-tink-fields/blob/main/docs/code-review-2026-09.md): findings, benchmark methodology, and remaining work. ## License -BSD-3-Clause. See [LICENSE.txt](LICENSE.txt). +BSD-3-Clause. See [LICENSE.txt](https://github.com/script3r/django-tink-fields/blob/main/LICENSE.txt). diff --git a/RELEASING.md b/RELEASING.md index 937b34d..3425e68 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,38 +1,106 @@ # Releasing -Releases are built from a published GitHub release and uploaded to PyPI using trusted publishing. +A published GitHub release triggers `.github/workflows/release.yml`, which builds +and uploads the wheel and source distribution to PyPI with trusted publishing. +Creating a draft does not publish packages. Publication is a separate action from +merging a PR or pushing a tag. -## One-time repository setup +## Repository setup -1. Configure a PyPI trusted publisher for the `django-tink-fields` project, this GitHub repository, the `release.yml` workflow, and the `pypi` environment. -2. Protect the GitHub `pypi` environment as appropriate for the project. +Configure a PyPI trusted publisher for this repository, the `release.yml` +workflow, and the `pypi` environment. Apply the repository's intended environment +protections. No long-lived PyPI password or API token is needed by the workflow. +Use an authenticated maintainer session to create the GitHub release. -No long-lived PyPI password or API token is required by the workflow. +## Prepare the release -## Release checklist - -1. Choose a semantic version and update `tink_fields/_version.py` and `CHANGELOG.md`. -2. Run the complete local gates: +1. Choose an unused semantic version. A change to accepted field definitions or + behavior requiring upgrade guidance can justify a minor release while the + package is pre-1.0; do not call such changes transparent patch upgrades. +2. Update `tink_fields/_version.py`, the version regression, and `CHANGELOG.md`. + Date the changelog entry for publication. Keep prior historical entries intact + unless a correction is explicitly explained. +3. Audit README examples, support claims, security policy, operations/upgrade + guides, example-project instructions, and the release notes. Describe known + limits and recovery requirements. Check local Markdown links and the contents + of the source distribution; linked guides must not disappear from packaging. +4. Run the release gates from a clean checkout, with all supported interpreters + installed: ```bash tox ruff check . ruff format --check . pyright --pythonpath "$(command -v python)" - python -m build - twine check --strict dist/* + pip-audit + bandit -r tink_fields -x tink_fields/test -ll + release_dist=$(mktemp -d /tmp/django-tink-fields-dist.XXXXXX) + python -m build --outdir "$release_dist" + twine check --strict "$release_dist"/* ``` -3. Confirm CI passes on the release commit. -4. Create and push a signed tag matching the package version: + Inspect the wheel and source distribution. Tests and fixture keysets must + remain excluded. Use a fresh directory so old distributions cannot be + mistaken for this release. Smoke-test the built wheel in a clean environment. +5. Merge the release preparation PR into `main` after CI passes. Fetch `main`, + verify that every intended feature commit is an ancestor, and confirm CI + passes for the **exact commit to be tagged**. A merged stacked PR may only be + in a feature branch, so PR status alone is insufficient. - ```bash - git tag -s v0.4.0 -m "Release 0.4.0" - git push origin v0.4.0 - ``` +## Tag and publish + +Create an annotated tag pointing at the verified commit, not an implicit latest +branch tip. Sign it with `git tag -s` when a trusted signing identity is configured. +Otherwise use `git tag -a`; an unsigned annotated tag must not be described as a +signed or identity-verified tag. Do not create an untrusted signing key solely to +make a release appear signed. + +For a 0.5.0 release, after choosing the verified commit: + +```bash +release_version=0.5.0 +release_tag="v${release_version}" +release_commit=$(git rev-parse origin/main) +git tag -a "$release_tag" "$release_commit" -m "Release ${release_version}" +git push origin "$release_tag" +``` + +The commands assume `origin/main` still identifies the commit whose CI was +checked. Recheck if the branch moved. Never move or replace an existing release +tag to publish different code. + +Create a draft from that existing tag and the reviewed release notes: + +```bash +gh release create "$release_tag" --verify-tag --draft \ + --title "$release_tag" --notes-file /path/to/release-notes.md +gh release view "$release_tag" +``` + +Verify the version, target commit, migration instructions, and links in the +draft, then publish it: + +```bash +gh release edit "$release_tag" --draft=false --latest +``` + +The workflow checks that the tag matches the package version, builds and +validates both distributions, and smoke-tests the wheel before publishing. It +does not itself rerun the full test matrix; checking CI on the tagged commit is +a release responsibility. + +## Verify publication -5. Draft a GitHub release from that existing tag using the matching changelog section. Review it, then publish it. -6. Confirm the publish workflow succeeds and the wheel and source distribution appear on PyPI. -7. Install the exact version from PyPI into a clean environment and import `tink_fields`. +1. Confirm the `Publish release` workflow succeeds. +2. Check PyPI's version-specific JSON metadata for both the wheel and source + distribution, including the version, upload records, and SHA-256 digests. +3. Install the exact version from PyPI in a fresh environment and run an isolated + import (`python -I`) so an editable checkout cannot mask a bad wheel. Verify + `tink_fields.__version__` and an encryption/decryption round trip. +4. Check the published README and upgrade links. Report the GitHub release and + PyPI version URLs, and distinguish publication from an unverified draft. -The workflow rejects a GitHub release whose tag does not match the package version, validates both distributions, smoke-tests the wheel in a clean environment, and only then publishes. +If publishing fails, inspect the workflow logs and whether any files already +reached PyPI before retrying. PyPI versions/files cannot be overwritten. Do not +move a released tag or upload different bytes under an existing filename; if a +published artifact is wrong, prepare a new version with explicit release notes. diff --git a/SECURITY.md b/SECURITY.md index 7417a9b..9276134 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,14 +2,40 @@ ## Supported versions -Security fixes are provided for the latest released minor version. Users should upgrade to the newest patch release before reporting a suspected known issue. +Security fixes are provided for the latest released minor line, currently +**0.5.x**. Upgrade to its newest patch release and read the +[upgrade guide](docs/upgrading-to-0.5.md); upgrading alone cannot reconstruct data +that an earlier version stored incorrectly. Older release lines do not receive +backported fixes under this policy. ## Reporting a vulnerability -Do not open a public issue for a suspected vulnerability. Use GitHub's private vulnerability reporting for this repository, or contact the maintainer at the address listed in `pyproject.toml`. Include the affected version, impact, reproduction steps, and any suggested mitigation. Do not include production keys, plaintext, ciphertext, credentials, or customer data. +Email the maintainer at [script3r@gmail.com](mailto:script3r@gmail.com). GitHub +private vulnerability reporting is not currently enabled for this repository; +do not open a public issue containing a suspected vulnerability or sensitive +reproduction data. -You should receive an acknowledgment within seven days. Disclosure timing will be coordinated after the report is reproduced and a fix is available. +Include the affected version, database backend, impact, and a minimal +reproduction using synthetic data and disposable test keys. Do not send +production keys, credentials, customer data, or production plaintext/ciphertext. +The maintainer aims to acknowledge reports within seven days; disclosure timing +will be coordinated after investigation. -## Scope notes +## Scope and known limitations -Reports about plaintext key exposure, authentication or authorization outside these model fields, compromised application processes, and properties explicitly documented under README security limitations may be out of scope unless the package behaves contrary to its documented guarantees. +Unexpected plaintext storage, authentication failures that are silently ignored, +incorrect key loading or invalidation, and behavior contrary to the package's +documented guarantees are relevant reports. This includes package defects that +expose keys; key exposure is not categorically excluded from review. + +Deterministic equality leakage, visible ciphertext length/nullness, and the +absence of row-specific AAD by default are documented properties. The package +does not protect data after decryption inside a compromised application or +replace application authentication, authorization, logging controls, or KMS +access policies. + +Deterministic key rotation does not automatically preserve cross-generation +lookups or plaintext uniqueness. Backend-dependent serialization can also affect +equality. These are known limitations, not features provided by the cache API. +See [operations and limitations](docs/operations.md) and the +[0.5.0 upgrade guide](docs/upgrading-to-0.5.md) for migration and recovery details. diff --git a/docs/code-review-2026-09.md b/docs/code-review-2026-09.md index 7172637..62e34bf 100644 --- a/docs/code-review-2026-09.md +++ b/docs/code-review-2026-09.md @@ -1,12 +1,12 @@ -# Code review and landing plan — 2026-09-05 +# Code review — 2026-09-05 Reviewed the library, tests, example project, packaging, and CI from `main` at `72c1661`. The original suite passed 76 library tests with 95.28% coverage, but -missed several value-conversion and ORM restriction failures. This stack adds +missed several value-conversion and ORM restriction failures. The reviewed changes add behavioral regressions instead of treating coverage percentage as proof of correctness. -## Changes in this stack +## Changes included in 0.5.0 | Priority | Finding and evidence | Resolution | | --- | --- | --- | @@ -16,9 +16,9 @@ correctness. | Medium | One hundred fields sharing a keyset constructed 100 separate primitives. | PR #9: share one primitive per type per cached keyset, retaining bounded caching and weak manager references. | | High | PostgreSQL binary writes encrypted the string representation of `psycopg.Binary`, corrupting the original contents. Four real-driver cases failed. | [PR #10](https://github.com/script3r/django-tink-fields/pull/10): convert buffer contents before encryption, and adapt only final ciphertext. | | Medium | Positional database options bypassed validation; randomized slug fields silently inherited an index. | [PR #11](https://github.com/script3r/django-tink-fields/pull/11): validate resolved options and serialize `db_index=False` for encrypted slugs. | -| Medium | User-relative keyset paths were checked before expansion; invalid encodings and incompatible AEAD primitives escaped as low-level exceptions. | Final keyset-loading PR: normalize paths and improve configuration errors. | -| Medium | CI omitted two advertised Python/Django combinations and never pinned the minimum Tink version. | Final PR: add Python 3.13/3.14 with Django 5.2 and a Tink 1.13.0 job; keep tox aligned. | -| Low | Keyset loading used the older reader/handle API and untyped handles. | Final PR: use Tink's explicit JSON format API and `KeysetHandle` annotations, with legacy keyset interoperability coverage. | +| Medium | User-relative keyset paths were checked before expansion; invalid encodings and incompatible AEAD primitives escaped as low-level exceptions. | [PR #12](https://github.com/script3r/django-tink-fields/pull/12): normalize paths and improve configuration errors. | +| Medium | CI omitted two advertised Python/Django combinations and never pinned the minimum Tink version. | PR #12: add Python 3.13/3.14 with Django 5.2 and a Tink 1.13.0 job; keep tox aligned. | +| Low | Keyset loading used the older reader/handle API and untyped handles. | PR #12: use Tink's explicit JSON format API and `KeysetHandle` annotations, with legacy keyset interoperability coverage. | ## Performance evidence @@ -38,29 +38,19 @@ or performance under contention. Encryption itself occurs outside the cache lock. Calls that obtained an old primitive can finish with it, and every worker process must reload or restart after rotation. -## Landing and rollout - -**Current landing order:** [PR #13](https://github.com/script3r/django-tink-fields/pull/13) -then [PR #12](https://github.com/script3r/django-tink-fields/pull/12). PR #7 is in -`main`. PRs #8–#11 were merged into their predecessor branches, so PR #13 carries -those four original commits into `main`. After #13 lands, change #12's base to -`main` before merging it (or verify GitHub has retargeted it automatically). - -Land the PRs in dependency order. Each PR targets its predecessor so its diff -contains only that change. After a parent lands, retarget the next PR to `main` -if GitHub has not done so automatically. Merge commits preserve the stack's -ancestry; squash or rebase merges require rebasing the remaining branches onto -the new `main` before landing them. - -- Existing correctly serialized ciphertext remains readable. No key or payload - format migration is introduced by the lookup, timezone, cache, or loading PRs. -- For encrypted slug fields, generate and apply the index-removal migration. - Positional configurations that violate documented restrictions now fail early. -- PostgreSQL binary rows already corrupted by adapter stringification require - application-specific recovery. In particular, a stored memoryview description - does not contain the original bytes and cannot be repaired by this patch. -- The final README corrects the validation claim: Django model `save()` does not - automatically call `full_clean()`. +## Release status and rollout + +All reviewed changes reached `main` in merge commit `cc51950` through PR #13, +including the modernization changes from PR #12. There is no remaining landing +order for this original stack. They are included in the 0.5.0 release preparation. + +The [upgrade guide](upgrading-to-0.5.md) is the current rollout reference. The +subsequent documentation audit confirmed that an old omitted slug index default +can produce **no autodetected migration** under the new field class; inspect the +physical index and use a reviewed database-specific migration when necessary. +Previously corrupted binary data or shifted datetimes cannot be repaired by +upgrading alone. See [operations](operations.md) for validation and rotation +limits. ## Follow-up priorities and limits @@ -93,15 +83,15 @@ the new `main` before landing them. validators without changing shared metadata and retain backend range checks. 5. **Low — improve typing and test organization incrementally.** The older - coverage-focused tests contain duplicate assertions and stale line-number - comments. Consolidate them around observable behavior while retaining the + coverage-focused tests contain duplicate assertions. Stale line-number and + cache implementation comments were corrected during release preparation. Consolidate tests around observable behavior while retaining the new regression cases. Package a `py.typed` marker only after testing the public Django field annotations with downstream type checkers. Module splits should preserve public field import paths used in existing migrations. ## Validation -At the top of the stack, all **137 library tests** and **6 example integration +At completion of the reviewed implementation, all **137 library tests** and **6 example integration tests** pass locally on both Python 3.14 / Django 6.0 / Tink 1.16.1 and Python 3.10 / Django 5.2 / Tink 1.13.0. Library coverage is **97.62%**. Ruff lint/format and Pyright pass. Distribution builds and strict Twine validation pass; the diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..9f5092f --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,115 @@ +# Operations and limitations + +## Configuration and key access + +`TINK_FIELDS_CONFIG` maps keyset names to these options: + +| Option | Requirement | +| --- | --- | +| `path` | Nonempty string or path-like object naming a readable Tink JSON keyset. `~` is expanded and the path is resolved on first use. | +| `cleartext` | Boolean, default `False`. `True` means the file contains the secret keys directly. | +| `master_key_aead` | A Tink `aead.Aead` instance, required when `cleartext=False`. Construct it with your KMS integration before using the field. | + +Field options `keyset="default"` and `aad_callback=...` select the configuration +and authenticated context. The callback receives the **field**, not a model +instance, and must return bytes. The default AAD is empty. It therefore does not +bind ciphertext to a row, tenant, field, or model; applications needing stable +field context must supply a callback. The callback alone cannot provide +row-specific authorization or tenant isolation. + +The package registers AEAD and deterministic AEAD primitives, but does not +provision KMS keys, discover credentials, or configure a KMS client. Encrypted +keyset loading uses empty keyset-encryption AAD; that is separate from the field's +payload AAD callback. A keyset encrypted with different keyset AAD needs an +appropriate external conversion before it can be loaded by this package. + +Models and migrations can be imported without opening key files. Successful +loads remain cached; editing settings or replacing a file does not automatically +refresh every active field. There is no filesystem watcher or polling interval. +Errors in the config, key file, encoding, or primitive choice surface on first +use as `ImproperlyConfigured`. Payload authentication failures propagate as +Tink errors; do not turn a failed decrypt into a missing or empty value. + +## Database behavior + +The encrypted column uses Django's `BinaryField` database type. Conversion to a +Python value happens when Django loads the field, including `values()` and +`values_list()` of the field itself. These calls return plaintext to application +code; encryption does not protect logs, exports, caches, or responses containing +those values. + +| Operation | Randomized | Deterministic | +| --- | --- | --- | +| `isnull`, `field=None` | Supported | Supported | +| Exact equality | Rejected | Supported for matching prepared bytes/key/AAD | +| `in`, range, substring, case-insensitive lookups | Rejected | Rejected | +| JSON key and date transforms | Rejected | Rejected | +| Field-level `db_index` and `unique` | Rejected | Supported, subject to representation and rotation limits | +| `primary_key`, `db_default` | Rejected | Rejected | +| `F()` or other expressions assigned to a field; `bulk_update()` | Rejected | Rejected | + +Literal saves, `bulk_create()`, and literal `QuerySet.update()` encrypt values. +Django does not run `full_clean()` automatically for these operations. A model +form validates its included fields; call `full_clean()` yourself when required +for other write paths. + +Lookup restrictions apply to the field's lookup/transform interface. They are +not a SQL sandbox: explicit SQL functions, casts, ordering, aggregates, joins, +custom constraints, and raw SQL can still operate on ciphertext and have no +implied plaintext semantics. `Meta.indexes` and `Meta.constraints` are not a +substitute for the field-level restrictions or a supported way to query +randomized ciphertext. Do not use database arithmetic or SQL transformations to +modify encrypted values. + +Deterministic equality compares serialized bytes, not a plaintext database +collation or a normalized application identity. PostgreSQL datetime offsets and +backend-specific UUID representations are known examples where equivalent +application values can serialize differently. Changing backend, connection +timezone, normalization, keyset, or AAD may require a data migration. Full server +integration is currently tested only with SQLite; psycopg coverage exercises +actual driver adaptation without a PostgreSQL server. MySQL and Oracle server +behavior is not established by the suite. + +## Cache behavior and performance + +Keyset handles and their AEAD/DAEAD primitives are shared across managers for +matching paths, file metadata, cleartext mode, and hashable master AEAD values. +An unhashable master AEAD is supported but bypasses the shared lookup cache. +The shared lookup cache holds up to 32 entries. Active fields can retain entries +that were evicted from that shared cache, so this is not a hard process-wide +memory bound. + +`clear_keyset_cache()` clears cached entries referenced by active managers and +waits for primitive construction/publication under the cache lock. Operations +that already obtained a primitive may finish with it. Encryption and decryption +happen outside that lock. Reloading one process does not reload another process +or update an independently retained primitive in application code. + +Use `python -m benchmarks.keyset_cache` from a repository checkout to measure +shared-keyset initialization and warm encryption on your environment. The +[review measurements](code-review-2026-09.md#performance-evidence) show reduced +initialization work and a small warm-call synchronization cost, not a universal +throughput improvement. + +## Rotation and data migration + +For randomized encryption, a typical rotation adds a primary key while retaining +old enabled keys for decryption. Replace the keyset atomically and reload all +workers. Verify old-data reads and new-data writes before disabling an old key; +old backups may also need it. Retaining an old key does not rewrite old rows. + +For deterministic encryption, a new primary key changes the ciphertext produced +for the same prepared value. Exact lookups then miss old ciphertext, and a +unique ciphertext index can admit a plaintext duplicate across generations. +There is no automatic multi-generation lookup or rotation migration API. +Coordinate writes, rewrite affected rows under the chosen generation, and verify +plaintext uniqueness and recovery as part of an application-specific migration. +Do not treat a process restart as that migration. + +Likewise, converting a plaintext column to an encrypted field is not an in-place +schema-only change. Use a staged migration with separate storage, controlled +copying through the Python encryption path, verification, and a planned cutover. +Do not discard the original column or keys until recovery has been verified. + +See the [0.5.0 upgrade guide](upgrading-to-0.5.md) for known historical data and +index issues, and [SECURITY.md](../SECURITY.md) for reporting defects. diff --git a/docs/upgrading-to-0.5.md b/docs/upgrading-to-0.5.md new file mode 100644 index 0000000..70cd8c9 --- /dev/null +++ b/docs/upgrading-to-0.5.md @@ -0,0 +1,113 @@ +# Upgrading to 0.5.0 + +Version 0.5.0 fixes persistence and query errors without changing the Tink keyset +format or deliberately rewriting existing ciphertext. It also enforces field +restrictions that older versions sometimes accepted. Review this guide before +upgrading a deployed application from 0.4.x or earlier. + +## Before deployment + +1. Back up the database and the keysets needed to decrypt it, and verify recovery + in a protected environment. Keep keys separate from database backups. +2. Upgrade a staging copy with `python -m pip install django-tink-fields==0.5.0`. + Exercise reads, writes, forms, migrations, and deterministic lookups using + your production database backend and timezone settings. +3. Review the cases below. Install the release on every worker and restart the + workers together with the planned application/schema changes. + +Do not rotate keys or change database/timezone settings as an incidental part +of this upgrade. Those changes need their own data migration plan. + +## Encrypted slug indexes + +`EncryptedSlugField` now defaults to `db_index=False`. Earlier releases inherited +Django's plaintext slug index even though randomized encryption cannot use it +for equality searches. + +Run `makemigrations` and inspect the actual database indexes. **An empty migration +plan does not prove that the old index is gone.** Old migration files can contain +`EncryptedSlugField()` without `db_index=True`; loading those migrations with +0.5.0 uses the new default, so Django may see no state change to migrate. + +Use your database's index inspection tools, or Django's +`connection.introspection.get_constraints(cursor, table_name)`, to identify the +old index. If it remains, create and review a backend-specific database migration +to remove that particular index. Preserve primary-key, unique, composite, and +application-defined indexes. An `AlterField` that changes `False` to `False` +will not reliably remove a physical index absent from Django's migration state. + +Existing rows remain readable while the unused index exists; removing it avoids +unnecessary storage and write overhead. Restoring the old application version +requires separately reviewing whether its expected index should be restored. + +## PostgreSQL binary data + +Older `EncryptedBinaryField` writes using psycopg could encrypt an adapter's text +representation instead of the original buffer. For example, a read could return +bytes describing `Binary(b'...')`; a memoryview could be represented only by an +object description. + +New writes encrypt the original bytes. Correctly stored old ciphertext remains +readable, but this release cannot automatically reconstruct corrupted values. +Validate suspect data against an authoritative source and recover it through an +application-specific migration or backup. Do not use `eval()` on recovered text +or assume every adapter-looking value is corrupt: legitimate payloads may +contain that text too. A memory address does not retain the original contents. + +## Datetimes + +Under `USE_TZ=True`, decrypted naive datetime representations now regain the +**database connection timezone**, matching the timezone used when they were +prepared for storage. This fixes timezone loss on SQLite and prevents subsequent +re-saves from shifting otherwise-correct values. Existing explicit UTC offsets +are preserved; `USE_TZ=False` keeps naive datetime behavior. + +The fix cannot detect timestamps already shifted by earlier read/save cycles. +Compare those values with an authoritative source if the application was +exposed to that behavior. Keep `USE_TZ` and the database connection's `TIME_ZONE` +consistent with the settings used to write existing data. + +Deterministic datetime equality still depends on the serialized representation. +In the PostgreSQL backend, equal instants with different UTC offsets can produce +different ciphertext. Normalize new application values consistently, and design +a migration for existing values before changing the representation policy. +This release does not introduce backend-independent canonical serialization. + +## Queries and field options + +- JSON key lookups, JSON projections such as `values("payload__key")`, date + transforms, and late-registered custom lookups now fail early. Randomized + fields support `isnull` and `field=None`; deterministic fields additionally + support exact equality. +- Positional `primary_key`, `unique`, `db_index`, and `db_default` arguments now + receive the same restrictions as keyword arguments. Review model definitions + and historical migrations that relied on a bypass. Primary keys and database + defaults are unsupported on all encrypted fields; only deterministic fields + allow field-level uniqueness and indexes. +- Database expressions, including the `CASE` expressions used by `bulk_update()`, + are unsupported for encrypted assignments. Use per-instance `save()` or a + literal `QuerySet.update()` as appropriate. Neither calls `full_clean()`. + +## Keysets and rotation + +Cleartext and encrypted JSON keysets remain compatible. `master_key_aead` must +be a Tink `Aead` instance for an encrypted keyset; a key URI or raw bytes alone +are not enough. User-relative paths are expanded before validation, and invalid +configuration is reported when the keyset is first needed. + +Primitive construction and cache invalidation are synchronized. An operation +that already obtained an old primitive can still finish with it. Invalidation is +local to one process: restart or explicitly reload every worker. + +Promoting a deterministic primary key changes lookup ciphertext. Retaining old +enabled keys preserves decryption, but exact lookups and plaintext uniqueness do +not span old and new ciphertext generations automatically. A coordinated data +migration is required; `clear_keyset_cache()` alone is insufficient. See +[operations and limitations](operations.md). + +## Older upgrades + +When upgrading from 0.3.x or earlier, also review the +[0.4.0 migration notes](../CHANGELOG.md#upgrading-from-03x-to-040). Older migrations +may have omitted custom `keyset` and `aad_callback` options. Correcting migration +state does not itself re-encrypt stored rows. diff --git a/example_project/README.md b/example_project/README.md new file mode 100644 index 0000000..d23dfb8 --- /dev/null +++ b/example_project/README.md @@ -0,0 +1,23 @@ +# Example integration project + +This is a test harness for model persistence, not a deployable service. Its +in-memory SQLite database, fixed secret key, cleartext test keysets, and +`USE_TZ=False` settings are for tests only. The library's separate datetime suite +also exercises `USE_TZ=True` and non-UTC connection timezones. + +From the repository root, install the test extra and run: + +```bash +python -m pip install -e ".[test]" +python -m pytest -c example_project/pytest.ini example_project/example_app/tests +``` + +The six tests cover field round trips, raw ciphertext, tamper rejection, stable +AAD, and deterministic lookup behavior. They do not test PostgreSQL, MySQL, or +Oracle servers. The library suite contains additional psycopg adapter tests that +do not need a running server. + +The example app disables migrations so pytest creates its tables directly. For +an application, configure persistent storage, your own secret key and keysets, +and normal Django migrations; review [operations](../docs/operations.md) and the +[upgrade guide](../docs/upgrading-to-0.5.md) before using existing data. diff --git a/tink_fields/_version.py b/tink_fields/_version.py index 80f8143..205855a 100644 --- a/tink_fields/_version.py +++ b/tink_fields/_version.py @@ -1,3 +1,3 @@ """Package version.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/tink_fields/fields.py b/tink_fields/fields.py index b6f5a08..a832afe 100644 --- a/tink_fields/fields.py +++ b/tink_fields/fields.py @@ -89,7 +89,7 @@ class KeysetConfig: Attributes: path: Path to the keyset file - master_key_aead: Master key for encrypted keysets (optional) + master_key_aead: Tink Aead wrapping primitive, required for encrypted keysets cleartext: Whether the keyset is in cleartext format """ @@ -676,12 +676,10 @@ def _to_python_prepare(self, value: bytes) -> bytes: class DeterministicEncryptedField(EncryptedField): """A field that uses Deterministic AEAD for searchable encryption. - Deterministic AEAD provides the same security guarantees as regular AEAD - but produces the same ciphertext for the same plaintext, making it - possible to search encrypted data. - - Note: Deterministic encryption is less secure than regular AEAD as it - reveals patterns in the data. Use only when searchability is required. + Deterministic AEAD authenticates the payload and associated data while + revealing equality of prepared plaintext under the same key and AAD. + Exact lookups compare ciphertext, so changes to key generation, backend + representation, or AAD require an explicit data migration plan. """ _unsupported_properties = frozenset(["primary_key", "db_default"]) diff --git a/tink_fields/test/test_coverage.py b/tink_fields/test/test_coverage.py index 3d7a5cf..7bc94c1 100644 --- a/tink_fields/test/test_coverage.py +++ b/tink_fields/test/test_coverage.py @@ -13,20 +13,20 @@ class TestKeysetConfigValidation: - """Test KeysetConfig validation methods for 100% coverage""" + """Test malformed keyset configuration.""" def test_keyset_config_empty_path(self): - """Test KeysetConfig validation with empty path (line 38)""" + """Test KeysetConfig validation with empty path""" with pytest.raises(ImproperlyConfigured, match="Keyset path cannot be None or empty"): KeysetConfig(path="") def test_keyset_config_none_path(self): - """Test KeysetConfig validation with None path (line 38)""" + """Test KeysetConfig validation with None path""" with pytest.raises(ImproperlyConfigured, match="Keyset path cannot be None or empty"): KeysetConfig(path=None) def test_keyset_config_nonexistent_path(self): - """Test KeysetConfig validation with non-existent path (line 41)""" + """Test KeysetConfig validation with non-existent path""" with pytest.raises(ImproperlyConfigured, match="is not a readable file"): KeysetConfig(path="/nonexistent/path/that/does/not/exist.json") @@ -48,7 +48,7 @@ def test_keyset_config_encrypted_without_master_key(self): class TestFieldPropertyValidation: - """Test field property validation for unsupported properties (line 60)""" + """Test field property validation for unsupported properties""" def test_primary_key_not_supported(self): """Test that primary_key property raises ImproperlyConfigured""" @@ -70,10 +70,10 @@ def test_unique_not_supported(self): class TestSettingsConfiguration: - """Test settings configuration validation (lines 73, 83)""" + """Test settings configuration validation""" def test_missing_tink_config(self): - """Test missing TINK_FIELDS_CONFIG in settings (line 73)""" + """Test missing TINK_FIELDS_CONFIG in settings""" with ( patch.object(settings, "TINK_FIELDS_CONFIG", None), pytest.raises( @@ -84,7 +84,7 @@ def test_missing_tink_config(self): EncryptedTextField()._get_aead_primitive() def test_missing_keyset_in_config(self): - """Test missing keyset in TINK_FIELDS_CONFIG (line 83)""" + """Test missing keyset in TINK_FIELDS_CONFIG""" with ( patch.object( settings, @@ -100,7 +100,7 @@ def test_missing_keyset_in_config(self): class TestCleartextKeysetHandling: - """Test cleartext keyset handling (line 94)""" + """Test cleartext keyset handling""" def test_cleartext_keyset_reading(self): """Test that cleartext keysets are read correctly""" @@ -111,30 +111,30 @@ def test_cleartext_keyset_reading(self): assert field._keyset_handle is not None def test_cleartext_keyset_primitive_creation(self): - """Test that cleartext keyset creates the correct primitive (line 94)""" + """Test that cleartext keyset creates the correct primitive""" # Use the existing working cleartext keyset field = EncryptedTextField(keyset="default") - # This should trigger the cleartext_keyset_handle.read(reader) path + # Loading the fixture should produce a usable AEAD primitive. primitive = field._get_aead_primitive() assert primitive is not None class TestDatabaseValueHandling: - """Test database value handling for None values (lines 105, 113-114)""" + """Test database value handling for None values""" def test_get_internal_type(self): - """Test get_internal_type method (line 101)""" + """Test get_internal_type method""" field = EncryptedTextField() assert field.get_internal_type() == "BinaryField" def test_get_db_prep_save_with_none(self): - """Test get_db_prep_save with None value (line 105)""" + """Test get_db_prep_save with None value""" field = EncryptedTextField() result = field.get_db_prep_save(None, connection) assert result is None def test_from_db_value_with_none(self): - """Test from_db_value with None value (lines 113-114)""" + """Test from_db_value with None value""" field = EncryptedTextField() result = field.from_db_value(None, None, connection) assert result is None @@ -149,7 +149,7 @@ def test_get_db_prep_save_with_value(self): class TestLookupErrors: - """Test unsupported lookup operations (line 139)""" + """Test unsupported lookup operations""" def test_exact_lookup_raises_error(self): """Test that exact lookup raises FieldError""" diff --git a/tink_fields/test/test_new_features.py b/tink_fields/test/test_new_features.py index 0a5976c..45df934 100644 --- a/tink_fields/test/test_new_features.py +++ b/tink_fields/test/test_new_features.py @@ -1,4 +1,4 @@ -"""Tests for new features from PR #2 implementation.""" +"""Binary fields, deterministic lookups, and primitive reuse.""" from unittest.mock import patch @@ -229,7 +229,7 @@ class TestMemoryLeakFix: """Test cases for memory leak fixes.""" def test_cached_property_usage(self): - """Test that cached_property is used instead of lru_cache.""" + """Repeated validator access returns the cached validators.""" field = DeterministicEncryptedTextField() # Get validators multiple times @@ -240,7 +240,7 @@ def test_cached_property_usage(self): assert validators1 is validators2 def test_keyset_manager_cached_properties(self): - """Test that KeysetManager uses cached_property correctly.""" + """Repeated access reuses the primitive; incompatible types fail.""" from tink_fields.fields import KeysetManager manager = KeysetManager("default", lambda x: b"") diff --git a/tink_fields/test/test_regressions.py b/tink_fields/test/test_regressions.py index 12a2d5f..4143bf5 100644 --- a/tink_fields/test/test_regressions.py +++ b/tink_fields/test/test_regressions.py @@ -29,7 +29,7 @@ def test_release_version() -> None: - assert __version__ == "0.4.0" + assert __version__ == "0.5.0" def test_fields_are_constructible_without_configured_keysets() -> None: