Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions tink_fields/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions tink_fields/test/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
67 changes: 67 additions & 0 deletions tink_fields/test/test_datetimes.py
Original file line number Diff line number Diff line change
@@ -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
Loading