diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc35bd..20add07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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. + - Reject inherited JSON/date transforms and late-registered plaintext lookups on encrypted columns; keep deterministic exact and SQL null lookups explicit. ## [0.4.0] - 2026-08-01 diff --git a/tink_fields/fields.py b/tink_fields/fields.py index d1cf9e7..d7c9ee0 100644 --- a/tink_fields/fields.py +++ b/tink_fields/fields.py @@ -11,6 +11,7 @@ from collections import OrderedDict from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass +from datetime import datetime from os import PathLike from pathlib import Path from threading import RLock @@ -21,6 +22,7 @@ from django.core.exceptions import FieldError, ImproperlyConfigured from django.db import models from django.db.models.lookups import Exact, IsNull, Lookup +from django.utils import timezone from django.utils.encoding import force_bytes, force_str from django.utils.functional import cached_property from tink import JsonKeysetReader, TinkError, aead, cleartext_keyset_handle, daead, read_keyset_handle @@ -428,9 +430,13 @@ def from_db_value( """ if value is not None: decrypted = self._keyset_manager.aead_primitive.decrypt(bytes(value), self._get_aad()) - return self.to_python(self._to_python_prepare(decrypted)) + return self._convert_decrypted_value(decrypted, connection) return None + def _convert_decrypted_value(self, value: bytes, connection: Any) -> Any: + """Restore the Python value after decrypting a binary database value.""" + return self.to_python(self._to_python_prepare(value)) + @cached_property def validators(self) -> list[Any]: """Get field validators. @@ -496,6 +502,14 @@ def as_sql(self, compiler: Any, connection: Any) -> tuple[str, tuple[Any, ...]]: return f"{lhs_sql} {rhs_sql}", (*lhs_params, *rhs_params) +def _restore_datetime_timezone(value: datetime, connection: Any) -> datetime: + # Binary columns skip the backend's DateTimeField result converters. + # Use the database timezone that was used to prepare existing ciphertext. + if settings.USE_TZ and timezone.is_naive(value): + return timezone.make_aware(value, connection.timezone) + return value + + # Field implementations class EncryptedTextField(EncryptedField, models.TextField): """Encrypted text field.""" @@ -595,7 +609,8 @@ class EncryptedDateField(EncryptedField, models.DateField): class EncryptedDateTimeField(EncryptedField, models.DateTimeField): """Encrypted datetime field.""" - pass + def _convert_decrypted_value(self, value: bytes, connection: Any) -> datetime: + return _restore_datetime_timezone(super()._convert_decrypted_value(value, connection), connection) class EncryptedBinaryField(EncryptedField, models.BinaryField): @@ -676,7 +691,7 @@ def from_db_value( """ if value is not None: decrypted = self._keyset_manager.daead_primitive.decrypt_deterministically(bytes(value), self._get_aad()) - return self.to_python(self._to_python_prepare(decrypted)) + return self._convert_decrypted_value(decrypted, connection) return None @@ -726,4 +741,5 @@ class DeterministicEncryptedDateField(DeterministicEncryptedField, models.DateFi class DeterministicEncryptedDateTimeField(DeterministicEncryptedField, models.DateTimeField): """Deterministic encrypted datetime field.""" - pass + def _convert_decrypted_value(self, value: bytes, connection: Any) -> datetime: + return _restore_datetime_timezone(super()._convert_decrypted_value(value, connection), connection) diff --git a/tink_fields/test/models.py b/tink_fields/test/models.py index ff444a7..5c32012 100644 --- a/tink_fields/test/models.py +++ b/tink_fields/test/models.py @@ -95,3 +95,7 @@ class DeterministicEncryptedExtended(models.Model): class DeterministicEncryptedUnique(models.Model): value = fields.DeterministicEncryptedCharField(max_length=25, keyset="deterministic", unique=True) + + +class DeterministicEncryptedDateTime(models.Model): + value = fields.DeterministicEncryptedDateTimeField(keyset="deterministic") diff --git a/tink_fields/test/test_datetimes.py b/tink_fields/test/test_datetimes.py new file mode 100644 index 0000000..186f328 --- /dev/null +++ b/tink_fields/test/test_datetimes.py @@ -0,0 +1,67 @@ +"""Timezone conversions must survive storage in a binary column.""" + +from datetime import datetime +from unittest.mock import patch +from zoneinfo import ZoneInfo + +import pytest +from django.db import connection +from django.test import override_settings +from django.utils import timezone +from django.utils.encoding import force_bytes + +from tink_fields import DeterministicEncryptedDateTimeField, EncryptedDateTimeField + +from . import models + + +@pytest.mark.django_db +@pytest.mark.parametrize("model", [models.EncryptedDateTime, models.DeterministicEncryptedDateTime]) +@pytest.mark.parametrize("database_timezone", ["UTC", "Asia/Kolkata"]) +@override_settings(USE_TZ=True, TIME_ZONE="America/New_York") +def test_aware_datetime_round_trip_and_resave(model, database_timezone): + original = datetime(2026, 9, 5, 12, 34, 56, 123456, tzinfo=ZoneInfo("Asia/Tokyo")) + with patch.object(connection, "timezone", ZoneInfo(database_timezone)): + instance = model.objects.create(value=original) + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {model._meta.db_table} WHERE id = %s", [instance.pk]) + ciphertext = bytes(cursor.fetchone()[0]) + assert force_bytes(str(original)) not in ciphertext + + instance.refresh_from_db() + assert timezone.is_aware(instance.value) + assert instance.value == original + assert model.objects.values_list("value", flat=True).get(pk=instance.pk) == original + + instance.save() + instance.refresh_from_db() + assert instance.value == original + if model is models.DeterministicEncryptedDateTime: + assert model.objects.get(value=original).pk == instance.pk + assert model.objects.get(value=instance.value).pk == instance.pk + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {model._meta.db_table} WHERE id = %s", [instance.pk]) + assert bytes(cursor.fetchone()[0]) == ciphertext + + +@pytest.mark.parametrize("field_cls", [EncryptedDateTimeField, DeterministicEncryptedDateTimeField]) +@pytest.mark.parametrize("plaintext", ["2026-09-05 03:04:05.123456", "2026-09-05 08:34:05.123456+05:30"]) +@override_settings(USE_TZ=True, TIME_ZONE="America/New_York") +def test_existing_datetime_ciphertext_remains_readable(field_cls, plaintext): + deterministic = field_cls is DeterministicEncryptedDateTimeField + field = field_cls(keyset="deterministic" if deterministic else "default") + if deterministic: + primitive = field._keyset_manager.daead_primitive + ciphertext = primitive.encrypt_deterministically(force_bytes(plaintext), b"") + else: + ciphertext = field._keyset_manager.aead_primitive.encrypt(force_bytes(plaintext), b"") + result = field.from_db_value(ciphertext, None, connection) + assert result == datetime(2026, 9, 5, 3, 4, 5, 123456, tzinfo=ZoneInfo("UTC")) + + +@pytest.mark.parametrize("field_cls", [EncryptedDateTimeField, DeterministicEncryptedDateTimeField]) +@pytest.mark.parametrize("value", [None, datetime(2026, 9, 5, 12, 34, 56)]) +@override_settings(USE_TZ=False) +def test_naive_datetime_and_null_round_trip(field_cls, value): + field = field_cls(keyset="deterministic" if field_cls is DeterministicEncryptedDateTimeField else "default") + assert field.from_db_value(field.get_db_prep_save(value, connection), None, connection) == value