diff --git a/tink_fields/__init__.py b/tink_fields/__init__.py index f09d251..5b92717 100644 --- a/tink_fields/__init__.py +++ b/tink_fields/__init__.py @@ -5,10 +5,27 @@ for cryptographic operations, ensuring data confidentiality and integrity. """ -# Register Tink AEAD primitives +# Register Tink primitives from tink import aead +# Try to import deterministic AEAD, fall back gracefully if not available +try: + from tink import daead + + DAEAD_AVAILABLE = True +except ImportError: + DAEAD_AVAILABLE = False + daead = None + from .fields import ( + DeterministicEncryptedCharField, + DeterministicEncryptedDateField, + DeterministicEncryptedDateTimeField, + DeterministicEncryptedEmailField, + DeterministicEncryptedField, + DeterministicEncryptedIntegerField, + DeterministicEncryptedTextField, + EncryptedBinaryField, EncryptedCharField, EncryptedDateField, EncryptedDateTimeField, @@ -19,6 +36,8 @@ ) aead.register() +if DAEAD_AVAILABLE: + daead.register() __version__ = "0.3.0" __all__ = [ @@ -29,4 +48,12 @@ "EncryptedIntegerField", "EncryptedDateField", "EncryptedDateTimeField", + "EncryptedBinaryField", + "DeterministicEncryptedField", + "DeterministicEncryptedTextField", + "DeterministicEncryptedCharField", + "DeterministicEncryptedEmailField", + "DeterministicEncryptedIntegerField", + "DeterministicEncryptedDateField", + "DeterministicEncryptedDateTimeField", ] diff --git a/tink_fields/fields.py b/tink_fields/fields.py index 6d87f73..6b9579b 100644 --- a/tink_fields/fields.py +++ b/tink_fields/fields.py @@ -8,7 +8,6 @@ from __future__ import annotations from dataclasses import dataclass -from functools import lru_cache from pathlib import Path from typing import Any, Optional @@ -16,6 +15,7 @@ from django.core.exceptions import FieldError, ImproperlyConfigured from django.db import models from django.utils.encoding import force_bytes, force_str +from django.utils.functional import cached_property from tink import ( JsonKeysetReader, @@ -24,6 +24,15 @@ read_keyset_handle, ) +# Try to import deterministic AEAD, fall back gracefully if not available +try: + from tink import daead + + DAEAD_AVAILABLE = True +except ImportError: + DAEAD_AVAILABLE = False + daead = None + __all__ = [ "EncryptedField", "EncryptedTextField", @@ -32,6 +41,14 @@ "EncryptedIntegerField", "EncryptedDateField", "EncryptedDateTimeField", + "EncryptedBinaryField", + "DeterministicEncryptedField", + "DeterministicEncryptedTextField", + "DeterministicEncryptedCharField", + "DeterministicEncryptedEmailField", + "DeterministicEncryptedIntegerField", + "DeterministicEncryptedDateField", + "DeterministicEncryptedDateTimeField", ] @@ -82,6 +99,116 @@ def validate(self): raise ImproperlyConfigured("Encrypted keysets must specify `master_key_aead`.") +class KeysetManager: + """Manages Tink keyset handles and primitives. + + This class provides a centralized way to manage keyset handles and + their associated primitives, with proper caching to avoid memory leaks. + """ + + def __init__(self, keyset_name: str, aad_callback: Any): + """Initialize the keyset manager. + + Args: + keyset_name: Name of the keyset to use + aad_callback: Callable for additional authenticated data + """ + self.keyset_name = keyset_name + self.aad_callback = aad_callback + self._keyset_handle = None + + # Validate configuration immediately + self._validate_config() + + def _validate_config(self): + """Validate the keyset configuration. + + Raises: + ImproperlyConfigured: If the configuration is invalid + """ + config = self._get_config() + + if self.keyset_name not in config: + raise ImproperlyConfigured( + f"Could not find configuration for keyset `{self.keyset_name}` " f"in `TINK_FIELDS_CONFIG`." + ) + + def _get_config(self): + """Get the Tink fields configuration from Django settings. + + Returns: + Dictionary containing keyset configurations + + Raises: + ImproperlyConfigured: If TINK_FIELDS_CONFIG is not found in settings + """ + config = getattr(settings, "TINK_FIELDS_CONFIG", None) + if config is None: + raise ImproperlyConfigured("Could not find `TINK_FIELDS_CONFIG` attribute in settings.") + return config + + def _get_tink_keyset_handle(self): + """Read the configuration for the requested keyset and return a keyset handle. + + Returns: + KeysetHandle: The configured Tink keyset handle + + Raises: + ImproperlyConfigured: If keyset configuration is invalid or missing + """ + if self._keyset_handle is None: + config = self._get_config() + + if self.keyset_name not in config: + raise ImproperlyConfigured( + f"Could not find configuration for keyset `{self.keyset_name}` " f"in `TINK_FIELDS_CONFIG`." + ) + + keyset_config = KeysetConfig(**config[self.keyset_name]) + + with open(keyset_config.path, "r", encoding="utf-8") as f: + reader = JsonKeysetReader(f.read()) + if keyset_config.cleartext: + self._keyset_handle = cleartext_keyset_handle.read(reader) + else: + self._keyset_handle = read_keyset_handle(reader, keyset_config.master_key_aead) + + return self._keyset_handle + + @cached_property + def aead_primitive(self): + """Get the AEAD primitive for encryption/decryption operations. + + Returns: + aead.Aead: The AEAD primitive instance + """ + return self._get_tink_keyset_handle().primitive(aead.Aead) + + @cached_property + def daead_primitive(self): + """Get the Deterministic AEAD primitive for encryption/decryption operations. + + Returns: + daead.DeterministicAead: The Deterministic AEAD primitive instance + + Raises: + ImproperlyConfigured: If deterministic AEAD is not available or keyset doesn't support it + """ + if not DAEAD_AVAILABLE: + raise ImproperlyConfigured( + "Deterministic AEAD is not available in this version of Tink. " + "Please upgrade to a newer version that supports deterministic AEAD." + ) + + try: + return self._get_tink_keyset_handle().primitive(daead.DeterministicAead) + except Exception as e: + raise ImproperlyConfigured( + f"Current keyset does not support deterministic AEAD: {e}. " + "Please use a keyset that contains deterministic AEAD keys." + ) + + class EncryptedField(models.Field): """A field that uses Tink primitives to protect data confidentiality and integrity. @@ -114,58 +241,42 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._keyset = kwargs.pop("keyset", DEFAULT_KEYSET) self._aad_callback = kwargs.pop("aad_callback", DEFAULT_AAD_CALLBACK) - # Initialize keyset handle - self._keyset_handle = self._get_tink_keyset_handle() - - # Call parent constructor + # Call parent constructor first super().__init__(*args, **kwargs) - def _get_config(self): - """Get the Tink fields configuration from Django settings. + # Initialize keyset manager after parent constructor + # This ensures the field is properly initialized before accessing settings + self._keyset_manager = KeysetManager(self._keyset, self._aad_callback) - Returns: - Dictionary containing keyset configurations + def _to_python_prepare(self, value: bytes) -> str: + """Prepare decrypted value for to_python conversion. - Raises: - ImproperlyConfigured: If TINK_FIELDS_CONFIG is not found in settings - """ - config = getattr(settings, "TINK_FIELDS_CONFIG", None) - if config is None: - raise ImproperlyConfigured("Could not find `TINK_FIELDS_CONFIG` attribute in settings.") - return config - - def _get_tink_keyset_handle(self): - """Read the configuration for the requested keyset and return a keyset handle. + Args: + value: Decrypted bytes value Returns: - KeysetHandle: The configured Tink keyset handle - - Raises: - ImproperlyConfigured: If keyset configuration is invalid or missing + str: String representation of the value """ - config = self._get_config() - - if self._keyset not in config: - raise ImproperlyConfigured( - f"Could not find configuration for keyset `{self._keyset}` " f"in `TINK_FIELDS_CONFIG`." - ) - - keyset_config = KeysetConfig(**config[self._keyset]) - - with open(keyset_config.path, "r", encoding="utf-8") as f: - reader = JsonKeysetReader(f.read()) - if keyset_config.cleartext: - return cleartext_keyset_handle.read(reader) - return read_keyset_handle(reader, keyset_config.master_key_aead) + return force_str(value) - @lru_cache(maxsize=None) def _get_aead_primitive(self): """Get the AEAD primitive for encryption/decryption operations. + This method is kept for backward compatibility with tests. + Returns: aead.Aead: The AEAD primitive instance """ - return self._keyset_handle.primitive(aead.Aead) + return self._keyset_manager.aead_primitive + + @property + def _keyset_handle(self): + """Get the keyset handle for backward compatibility. + + Returns: + KeysetHandle: The configured Tink keyset handle + """ + return self._keyset_manager._get_tink_keyset_handle() def get_internal_type(self): """Return the internal Django field type. @@ -188,7 +299,7 @@ def get_db_prep_save(self, value: Any, connection: Any) -> Any: val = super().get_db_prep_save(value, connection) if val is not None: return connection.Database.Binary( - self._get_aead_primitive().encrypt(force_bytes(val), self._aad_callback(self)) + self._keyset_manager.aead_primitive.encrypt(force_bytes(val), self._aad_callback(self)) ) return None @@ -211,11 +322,11 @@ def from_db_value( Decrypted and converted Python object, or None if value is None """ if value is not None: - return self.to_python(force_str(self._get_aead_primitive().decrypt(bytes(value), self._aad_callback(self)))) + decrypted = self._keyset_manager.aead_primitive.decrypt(bytes(value), self._aad_callback(self)) + return self.to_python(self._to_python_prepare(decrypted)) return None - @property - @lru_cache(maxsize=None) + @cached_property def validators(self) -> list[Any]: """Get field validators. @@ -266,16 +377,64 @@ def get_prep_lookup(self) -> None: ) +def _create_deterministic_lookup_class(lookup_name: str, base_lookup_class: type[Any]) -> type[Any]: + """Create a lookup class for deterministic encrypted fields. + + For deterministic fields, we support exact lookups by converting them to + 'in' lookups with all possible encrypted values. + + Args: + lookup_name: Name of the lookup operation + base_lookup_class: Base lookup class to inherit from + + Returns: + type: New lookup class for deterministic fields + """ + + def get_prep_lookup(self) -> Any: + """Handle lookups for deterministic encrypted fields.""" + if self.lookup_name == "exact": + # For exact lookups, we need to encrypt the value and use 'in' lookup + value = self.rhs + if value is None: + return None + + # Get the field instance + field = self.lhs.field + if hasattr(field, "_keyset_manager"): + # Encrypt the value using the field's keyset manager + encrypted_value = field._keyset_manager.daead_primitive.encrypt_deterministically( + force_bytes(value), field._aad_callback(field) + ) + # Return the encrypted value directly + return encrypted_value + else: + raise FieldError("Field does not have keyset manager for deterministic encryption.") + elif self.lookup_name == "isnull": + # isnull lookups are always supported + return self.rhs + else: + # All other lookups are not supported + raise FieldError(f"{self.lhs.field.__class__.__name__} `{self.lookup_name}` " f"does not support lookups.") + + return type( + f"DeterministicEncryptedField{lookup_name}", + (base_lookup_class,), + {"get_prep_lookup": get_prep_lookup}, + ) + + def _register_lookup_classes(): """Register lookup classes for encrypted fields.""" for name, lookup in models.Field.class_lookups.items(): if name != "isnull": + # Register lookup class for regular encrypted fields lookup_class = _create_lookup_class(name, lookup) EncryptedField.register_lookup(lookup_class) - -# Register lookup classes at module level -_register_lookup_classes() + # Register lookup class for deterministic encrypted fields + deterministic_lookup_class = _create_deterministic_lookup_class(name, lookup) + DeterministicEncryptedField.register_lookup(deterministic_lookup_class) # Field implementations @@ -313,3 +472,122 @@ class EncryptedDateTimeField(EncryptedField, models.DateTimeField): """Encrypted datetime field.""" pass + + +class EncryptedBinaryField(EncryptedField, models.BinaryField): + """Encrypted binary field for storing binary data. + + This field is specifically designed for storing binary data that should + not be converted to strings during decryption. + """ + + def _to_python_prepare(self, value: bytes) -> bytes: + """Prepare decrypted value for to_python conversion. + + For binary fields, we return the raw bytes without string conversion. + + Args: + value: Decrypted bytes value + + Returns: + bytes: Raw bytes value + """ + return value + + +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. + """ + + def get_db_prep_save(self, value: Any, connection: Any) -> Any: + """Prepare the value for saving to the database using deterministic encryption. + + Args: + value: The value to be saved + connection: Database connection + + Returns: + Binary object containing deterministically encrypted data, or None if value is None + """ + # Call the grandparent's get_db_prep_save to avoid using regular AEAD + val = super(EncryptedField, self).get_db_prep_save(value, connection) + if val is not None: + return connection.Database.Binary( + self._keyset_manager.daead_primitive.encrypt_deterministically( + force_bytes(val), self._aad_callback(self) + ) + ) + return None + + def from_db_value( + self, + value: Any, + expression: Any, + connection: Any, + *args: Any, + ) -> Any: + """Convert database value to Python object using deterministic decryption. + + Args: + value: Raw value from database + expression: Database expression + connection: Database connection + *args: Additional arguments + + Returns: + Decrypted and converted Python object, or None if value is None + """ + if value is not None: + decrypted = self._keyset_manager.daead_primitive.decrypt_deterministically( + bytes(value), self._aad_callback(self) + ) + return self.to_python(self._to_python_prepare(decrypted)) + return None + + +# Deterministic field implementations +class DeterministicEncryptedTextField(DeterministicEncryptedField, models.TextField): + """Deterministic encrypted text field.""" + + pass + + +class DeterministicEncryptedCharField(DeterministicEncryptedField, models.CharField): + """Deterministic encrypted character field.""" + + pass + + +class DeterministicEncryptedEmailField(DeterministicEncryptedField, models.EmailField): + """Deterministic encrypted email field.""" + + pass + + +class DeterministicEncryptedIntegerField(DeterministicEncryptedField, models.IntegerField): + """Deterministic encrypted integer field.""" + + pass + + +class DeterministicEncryptedDateField(DeterministicEncryptedField, models.DateField): + """Deterministic encrypted date field.""" + + pass + + +class DeterministicEncryptedDateTimeField(DeterministicEncryptedField, models.DateTimeField): + """Deterministic encrypted datetime field.""" + + pass + + +# Register lookup classes at module level +_register_lookup_classes() diff --git a/tink_fields/test/models.py b/tink_fields/test/models.py index 3972533..c9a10ed 100644 --- a/tink_fields/test/models.py +++ b/tink_fields/test/models.py @@ -48,3 +48,27 @@ class EncryptedCharWithAlternateKeyset(models.Model): class EncryptedCharWithCleartextKeyset(models.Model): value = fields.EncryptedCharField(max_length=25, keyset="cleartext_test") + + +class DeterministicEncryptedText(models.Model): + value = fields.DeterministicEncryptedTextField(keyset="deterministic") + + +class DeterministicEncryptedChar(models.Model): + value = fields.DeterministicEncryptedCharField(max_length=25, keyset="deterministic") + + +class DeterministicEncryptedInteger(models.Model): + value = fields.DeterministicEncryptedIntegerField(keyset="deterministic") + + +class EncryptedBinary(models.Model): + value = fields.EncryptedBinaryField(null=True) + + +class DeterministicEncryptedEmail(models.Model): + value = fields.DeterministicEncryptedEmailField(keyset="deterministic") + + +class DeterministicEncryptedTextNullable(models.Model): + value = fields.DeterministicEncryptedTextField(null=True, keyset="deterministic") diff --git a/tink_fields/test/settings/sqlite.py b/tink_fields/test/settings/sqlite.py index f1156ac..34fc7eb 100644 --- a/tink_fields/test/settings/sqlite.py +++ b/tink_fields/test/settings/sqlite.py @@ -30,4 +30,8 @@ "cleartext": True, "path": os.path.join(HERE, "../test_cleartext_keyset.json"), }, + "deterministic": { + "cleartext": True, + "path": os.path.join(HERE, "../test_deterministic_keyset.json"), + }, } diff --git a/tink_fields/test/test_deterministic_keyset.json b/tink_fields/test/test_deterministic_keyset.json new file mode 100644 index 0000000..e77f0db --- /dev/null +++ b/tink_fields/test/test_deterministic_keyset.json @@ -0,0 +1 @@ +{"primaryKeyId":2101871914,"key":[{"keyData":{"typeUrl":"type.googleapis.com/google.crypto.tink.AesSivKey","value":"EkCrDMQ2iRdYAiPm/9d8es3UMDe7y5+hr6z/Kq0EKoPB/5BSHEUvFH+QzfC+hJu28/GX9zNp46pHg3gvqHaPWDMS","keyMaterialType":"SYMMETRIC"},"status":"ENABLED","keyId":2101871914,"outputPrefixType":"TINK"}]} diff --git a/tink_fields/test/test_new_features.py b/tink_fields/test/test_new_features.py new file mode 100644 index 0000000..2925292 --- /dev/null +++ b/tink_fields/test/test_new_features.py @@ -0,0 +1,263 @@ +"""Tests for new features from PR #2 implementation.""" + +from unittest.mock import patch + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import connection + +import pytest + +from tink_fields.fields import DAEAD_AVAILABLE, DeterministicEncryptedTextField + + +@pytest.mark.django_db +class TestEncryptedBinaryField: + """Test cases for EncryptedBinaryField.""" + + def test_binary_field_encryption(self): + """Test that binary data is encrypted and decrypted correctly.""" + from tink_fields.test.models import EncryptedBinary + + # Test data + test_data = b"binary data \x00\x01\x02\x03" + + # Create and save + obj = EncryptedBinary.objects.create(value=test_data) + obj.refresh_from_db() + + # Verify decryption + assert obj.value == test_data + + # Verify encryption in database + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {EncryptedBinary._meta.db_table} WHERE id = %s", [obj.id]) + encrypted_data = cursor.fetchone()[0] + + # Should be encrypted (not equal to original) + assert encrypted_data != test_data + + def test_binary_field_none_value(self): + """Test that None values are handled correctly.""" + from tink_fields.test.models import EncryptedBinary + + obj = EncryptedBinary.objects.create(value=None) + obj.refresh_from_db() + assert obj.value is None + + +@pytest.mark.django_db +class TestDeterministicEncryption: + """Test cases for deterministic encryption fields.""" + + def test_deterministic_text_field(self): + """Test deterministic text field encryption.""" + from django.db import connection + + from tink_fields.test.models import DeterministicEncryptedText + + test_value = "test value" + + # Create two objects with same value + obj1 = DeterministicEncryptedText.objects.create(value=test_value) + obj2 = DeterministicEncryptedText.objects.create(value=test_value) + + # Both should decrypt to same value + assert obj1.value == test_value + assert obj2.value == test_value + + # But encrypted values should be identical (deterministic) + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {DeterministicEncryptedText._meta.db_table} WHERE id = %s", [obj1.id]) + encrypted1 = cursor.fetchone()[0] + cursor.execute(f"SELECT value FROM {DeterministicEncryptedText._meta.db_table} WHERE id = %s", [obj2.id]) + encrypted2 = cursor.fetchone()[0] + + assert encrypted1 == encrypted2 + + def test_deterministic_char_field(self): + """Test deterministic char field encryption.""" + from tink_fields.test.models import DeterministicEncryptedChar + + test_value = "test char" + + obj1 = DeterministicEncryptedChar.objects.create(value=test_value) + obj2 = DeterministicEncryptedChar.objects.create(value=test_value) + + assert obj1.value == test_value + assert obj2.value == test_value + + # Verify deterministic encryption + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {DeterministicEncryptedChar._meta.db_table} WHERE id = %s", [obj1.id]) + encrypted1 = cursor.fetchone()[0] + cursor.execute(f"SELECT value FROM {DeterministicEncryptedChar._meta.db_table} WHERE id = %s", [obj2.id]) + encrypted2 = cursor.fetchone()[0] + + assert encrypted1 == encrypted2 + + def test_deterministic_integer_field(self): + """Test deterministic integer field encryption.""" + from tink_fields.test.models import DeterministicEncryptedInteger + + test_value = 42 + + obj1 = DeterministicEncryptedInteger.objects.create(value=test_value) + obj2 = DeterministicEncryptedInteger.objects.create(value=test_value) + + assert obj1.value == test_value + assert obj2.value == test_value + + # Verify deterministic encryption + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {DeterministicEncryptedInteger._meta.db_table} WHERE id = %s", [obj1.id]) + encrypted1 = cursor.fetchone()[0] + cursor.execute(f"SELECT value FROM {DeterministicEncryptedInteger._meta.db_table} WHERE id = %s", [obj2.id]) + encrypted2 = cursor.fetchone()[0] + + assert encrypted1 == encrypted2 + + def test_deterministic_email_field(self): + """Test deterministic email field encryption.""" + from tink_fields.test.models import DeterministicEncryptedEmail + + test_value = "test@example.com" + + obj1 = DeterministicEncryptedEmail.objects.create(value=test_value) + obj2 = DeterministicEncryptedEmail.objects.create(value=test_value) + + assert obj1.value == test_value + assert obj2.value == test_value + + # Verify deterministic encryption + with connection.cursor() as cursor: + cursor.execute(f"SELECT value FROM {DeterministicEncryptedEmail._meta.db_table} WHERE id = %s", [obj1.id]) + encrypted1 = cursor.fetchone()[0] + cursor.execute(f"SELECT value FROM {DeterministicEncryptedEmail._meta.db_table} WHERE id = %s", [obj2.id]) + encrypted2 = cursor.fetchone()[0] + + assert encrypted1 == encrypted2 + + +@pytest.mark.django_db +class TestDeterministicLookups: + """Test cases for deterministic field lookups.""" + + def test_deterministic_exact_lookup(self): + """Test that exact lookups work with deterministic fields.""" + from tink_fields.test.models import DeterministicEncryptedText + + # Create test data + DeterministicEncryptedText.objects.create(value="value1") + DeterministicEncryptedText.objects.create(value="value2") + DeterministicEncryptedText.objects.create(value="value1") # Duplicate + + # Test exact lookup + results = DeterministicEncryptedText.objects.filter(value="value1") + assert results.count() == 2 + + # Test exact lookup with different value + results = DeterministicEncryptedText.objects.filter(value="value2") + assert results.count() == 1 + + def test_deterministic_isnull_lookup(self): + """Test that isnull lookups work with deterministic fields.""" + from tink_fields.test.models import DeterministicEncryptedTextNullable + + # Create test data + DeterministicEncryptedTextNullable.objects.create(value="value1") + DeterministicEncryptedTextNullable.objects.create(value=None) + + # Test isnull lookup + results = DeterministicEncryptedTextNullable.objects.filter(value__isnull=True) + assert results.count() == 1 + + results = DeterministicEncryptedTextNullable.objects.filter(value__isnull=False) + assert results.count() == 1 + + def test_deterministic_unsupported_lookup_raises_error(self): + """Test that unsupported lookups raise FieldError.""" + from tink_fields.test.models import DeterministicEncryptedText + + DeterministicEncryptedText.objects.create(value="value1") + + # Test that unsupported lookups raise FieldError + with pytest.raises(Exception): # FieldError or similar + DeterministicEncryptedText.objects.filter(value__contains="value").count() + + +@pytest.mark.django_db +class TestKeysetManager: + """Test cases for KeysetManager functionality.""" + + def test_keyset_manager_validation(self): + """Test that KeysetManager validates configuration on init.""" + from tink_fields.fields import KeysetManager + + # Test missing TINK_FIELDS_CONFIG + with patch.object(settings, "TINK_FIELDS_CONFIG", None): + with pytest.raises(ImproperlyConfigured): + KeysetManager("default", lambda x: b"") + + # Test missing keyset in config + with patch.object(settings, "TINK_FIELDS_CONFIG", {}): + with pytest.raises(ImproperlyConfigured): + KeysetManager("nonexistent", lambda x: b"") + + def test_keyset_manager_caching(self): + """Test that KeysetManager properly caches primitives.""" + from tink_fields.fields import KeysetManager + + # Test regular AEAD caching with default keyset + manager = KeysetManager("default", lambda x: b"") + + # Get primitive multiple times + primitive1 = manager.aead_primitive + primitive2 = manager.aead_primitive + + # Should be the same instance (cached) + assert primitive1 is primitive2 + + # Test deterministic primitive caching with deterministic keyset + daead_manager = KeysetManager("deterministic", lambda x: b"") + + if DAEAD_AVAILABLE: + daead1 = daead_manager.daead_primitive + daead2 = daead_manager.daead_primitive + assert daead1 is daead2 + else: + # Should raise ImproperlyConfigured when not available + with pytest.raises(ImproperlyConfigured): + daead_manager.daead_primitive + + +@pytest.mark.django_db +class TestMemoryLeakFix: + """Test cases for memory leak fixes.""" + + def test_cached_property_usage(self): + """Test that cached_property is used instead of lru_cache.""" + field = DeterministicEncryptedTextField() + + # Get validators multiple times + validators1 = field.validators + validators2 = field.validators + + # Should be the same instance (cached) + assert validators1 is validators2 + + def test_keyset_manager_cached_properties(self): + """Test that KeysetManager uses cached_property correctly.""" + from tink_fields.fields import KeysetManager + + manager = KeysetManager("default", lambda x: b"") + + # Test that properties are cached + aead1 = manager.aead_primitive + aead2 = manager.aead_primitive + assert aead1 is aead2 + + # Test deterministic primitive - should raise ImproperlyConfigured + # because current keyset doesn't support deterministic AEAD + with pytest.raises(ImproperlyConfigured): + manager.daead_primitive