-
-
Notifications
You must be signed in to change notification settings - Fork 18
feat: Add KeyringEncryptionWrapper for system keychain-based encryption #296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
strawgate
wants to merge
1
commit into
main
Choose a base branch
from
claude/issue-293-20260203-0844
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| from key_value.aio.wrappers.encryption.base import BaseEncryptionWrapper | ||
| from key_value.aio.wrappers.encryption.fernet import FernetEncryptionWrapper | ||
| from key_value.aio.wrappers.encryption.keyring import KeyringEncryptionWrapper | ||
|
|
||
| __all__ = ["BaseEncryptionWrapper", "FernetEncryptionWrapper"] | ||
| __all__ = ["BaseEncryptionWrapper", "FernetEncryptionWrapper", "KeyringEncryptionWrapper"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Keyring-based encryption wrapper that stores keys in the system keychain.""" | ||
|
|
||
| from cryptography.fernet import Fernet, MultiFernet | ||
|
|
||
| from key_value.aio.protocols.key_value import AsyncKeyValue | ||
| from key_value.aio.wrappers.encryption.base import BaseEncryptionWrapper | ||
| from key_value.shared.errors import EncryptionVersionError | ||
|
|
||
| try: | ||
| import keyring | ||
| except ImportError as e: | ||
| msg = "KeyringEncryptionWrapper requires py-key-value-aio[keyring]" | ||
| raise ImportError(msg) from e | ||
|
|
||
| ENCRYPTION_VERSION = 1 | ||
| DEFAULT_SERVICE_NAME = "py-key-value-encryption" | ||
| DEFAULT_KEY_NAME = "encryption-key" | ||
|
|
||
|
|
||
| class KeyringEncryptionWrapper(BaseEncryptionWrapper): | ||
| """Wrapper that encrypts values using a Fernet key stored in the system keychain. | ||
|
|
||
| This wrapper automatically generates and stores an encryption key in the system | ||
| keychain on first use. Subsequent instantiations with the same service_name and | ||
| key_name will retrieve the existing key. | ||
|
|
||
| Key rotation is supported by providing old_keys parameter containing previously | ||
| used Fernet keys. The wrapper will use MultiFernet to try decryption with each | ||
| key in order. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| key_value: AsyncKeyValue, | ||
| *, | ||
| service_name: str = DEFAULT_SERVICE_NAME, | ||
| key_name: str = DEFAULT_KEY_NAME, | ||
| old_keys: list[bytes] | None = None, | ||
| raise_on_decryption_error: bool = True, | ||
| ) -> None: | ||
| """Initialize the keyring encryption wrapper. | ||
|
|
||
| Args: | ||
| key_value: The key-value store to wrap. | ||
| service_name: The service name for keychain storage. Defaults to "py-key-value-encryption". | ||
| key_name: The username/key name for keychain storage. Defaults to "encryption-key". | ||
| old_keys: Optional list of old Fernet keys (as bytes) for rotation support. | ||
| These keys will be tried after the current key for decryption. | ||
| raise_on_decryption_error: Whether to raise an exception if decryption fails. Defaults to True. | ||
| """ | ||
| self._service_name = service_name | ||
| self._key_name = key_name | ||
|
|
||
| # Get or generate the encryption key | ||
| key_str = keyring.get_password(service_name=service_name, username=key_name) | ||
| if key_str is None: | ||
| key = Fernet.generate_key() | ||
| keyring.set_password(service_name=service_name, username=key_name, password=key.decode("ascii")) | ||
| else: | ||
| key = key_str.encode("ascii") | ||
|
|
||
| # Build Fernet with optional old keys for rotation | ||
| if old_keys: | ||
| fernets = [Fernet(key), *[Fernet(old_key) for old_key in old_keys]] | ||
| fernet: Fernet | MultiFernet = MultiFernet(fernets) | ||
| else: | ||
| fernet = Fernet(key) | ||
|
|
||
| def encrypt_with_fernet(data: bytes) -> bytes: | ||
| return fernet.encrypt(data) | ||
|
|
||
| def decrypt_with_fernet(data: bytes, encryption_version: int) -> bytes: | ||
| if encryption_version > ENCRYPTION_VERSION: | ||
| msg = f"Decryption failed: encryption versions newer than {ENCRYPTION_VERSION} are not supported" | ||
| raise EncryptionVersionError(msg) | ||
| return fernet.decrypt(data) | ||
|
|
||
| super().__init__( | ||
| key_value=key_value, | ||
| encryption_fn=encrypt_with_fernet, | ||
| decryption_fn=decrypt_with_fernet, | ||
| encryption_version=ENCRYPTION_VERSION, | ||
| raise_on_decryption_error=raise_on_decryption_error, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
| from cryptography.fernet import Fernet | ||
| from dirty_equals import IsStr | ||
| from inline_snapshot import snapshot | ||
| from typing_extensions import override | ||
|
|
||
| from key_value.aio.stores.memory.store import MemoryStore | ||
| from key_value.aio.wrappers.encryption.keyring import ( | ||
| DEFAULT_KEY_NAME, | ||
| DEFAULT_SERVICE_NAME, | ||
| KeyringEncryptionWrapper, | ||
| ) | ||
| from key_value.shared.errors import DecryptionError | ||
| from tests.stores.base import BaseStoreTests | ||
|
|
||
|
|
||
| class MockKeyring: | ||
| """A simple mock keyring that stores passwords in memory.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self._storage: dict[tuple[str, str], str] = {} | ||
|
|
||
| def get_password(self, service_name: str, username: str) -> str | None: | ||
| return self._storage.get((service_name, username)) | ||
|
|
||
| def set_password(self, service_name: str, username: str, password: str) -> None: | ||
| self._storage[(service_name, username)] = password | ||
|
|
||
| def delete_password(self, service_name: str, username: str) -> None: | ||
| del self._storage[(service_name, username)] | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def mock_keyring() -> MockKeyring: | ||
| return MockKeyring() | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def patched_keyring(mock_keyring: MockKeyring): | ||
| """Patch the keyring module in the encryption wrapper.""" | ||
| with patch("key_value.aio.wrappers.encryption.keyring.keyring", mock_keyring): | ||
| yield mock_keyring | ||
|
|
||
|
|
||
| class TestKeyringEncryptionWrapper(BaseStoreTests): | ||
| @override | ||
| @pytest.fixture | ||
| async def store(self, memory_store: MemoryStore, patched_keyring: MockKeyring) -> KeyringEncryptionWrapper: | ||
| return KeyringEncryptionWrapper(key_value=memory_store) | ||
|
|
||
| async def test_encryption_encrypts_value( | ||
| self, store: KeyringEncryptionWrapper, memory_store: MemoryStore, patched_keyring: MockKeyring | ||
| ): | ||
| """Test that values are actually encrypted in the underlying store.""" | ||
| original_value = {"test": "value", "number": 123} | ||
| await store.put(collection="test", key="test", value=original_value) | ||
|
|
||
| # Check the underlying store - should be encrypted | ||
| raw_value = await memory_store.get(collection="test", key="test") | ||
| assert raw_value is not None | ||
| assert "__encrypted_data__" in raw_value | ||
| assert "__encryption_version__" in raw_value | ||
| assert isinstance(raw_value["__encrypted_data__"], str) | ||
|
|
||
| # The encrypted data should not contain the original value | ||
| assert "test" not in str(raw_value) | ||
| assert "value" not in str(raw_value) | ||
|
|
||
| # Retrieve through wrapper - should decrypt automatically | ||
| result = await store.get(collection="test", key="test") | ||
| assert result == original_value | ||
|
|
||
| async def test_key_generation_on_first_use(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that a key is generated on first use when none exists.""" | ||
| # Ensure no key exists | ||
| assert patched_keyring.get_password(service_name=DEFAULT_SERVICE_NAME, username=DEFAULT_KEY_NAME) is None | ||
|
|
||
| # Create wrapper - should generate key | ||
| KeyringEncryptionWrapper(key_value=memory_store) | ||
|
|
||
| # Key should now exist | ||
| stored_key = patched_keyring.get_password(service_name=DEFAULT_SERVICE_NAME, username=DEFAULT_KEY_NAME) | ||
| assert stored_key is not None | ||
| # Verify it's a valid Fernet key | ||
| Fernet(stored_key.encode("ascii")) | ||
|
|
||
| async def test_key_retrieval_on_subsequent_use(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that the same key is retrieved on subsequent instantiations.""" | ||
| # Create first wrapper | ||
| wrapper1 = KeyringEncryptionWrapper(key_value=memory_store) | ||
| original_value = {"test": "value"} | ||
| await wrapper1.put(collection="test", key="test", value=original_value) | ||
|
|
||
| # Create second wrapper - should use same key | ||
| wrapper2 = KeyringEncryptionWrapper(key_value=memory_store) | ||
|
|
||
| # Second wrapper should be able to decrypt data from first | ||
| result = await wrapper2.get(collection="test", key="test") | ||
| assert result == original_value | ||
|
|
||
| async def test_custom_service_name_and_key_name(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that custom service name and key name are used.""" | ||
| custom_service = "my-custom-service" | ||
| custom_key = "my-custom-key" | ||
|
|
||
| KeyringEncryptionWrapper(key_value=memory_store, service_name=custom_service, key_name=custom_key) | ||
|
|
||
| # Key should be stored under custom names | ||
| assert patched_keyring.get_password(service_name=custom_service, username=custom_key) is not None | ||
| # Default names should not have a key | ||
| assert patched_keyring.get_password(service_name=DEFAULT_SERVICE_NAME, username=DEFAULT_KEY_NAME) is None | ||
|
|
||
| async def test_encryption_with_string_key( | ||
| self, store: KeyringEncryptionWrapper, memory_store: MemoryStore, patched_keyring: MockKeyring | ||
| ): | ||
| """Test that encryption works with a string key.""" | ||
| original_value = {"test": "value"} | ||
| await store.put(collection="test", key="test", value=original_value) | ||
|
|
||
| round_trip_value = await store.get(collection="test", key="test") | ||
| assert round_trip_value == original_value | ||
|
|
||
| raw_result = await memory_store.get(collection="test", key="test") | ||
| assert raw_result == snapshot( | ||
| { | ||
| "__encrypted_data__": IsStr(min_length=32), | ||
| "__encryption_version__": 1, | ||
| } | ||
| ) | ||
|
|
||
| async def test_encryption_many_operations( | ||
| self, store: KeyringEncryptionWrapper, memory_store: MemoryStore, patched_keyring: MockKeyring | ||
| ): | ||
| """Test that encryption works with put_many and get_many.""" | ||
| keys = ["k1", "k2", "k3"] | ||
| values = [{"data": "value1"}, {"data": "value2"}, {"data": "value3"}] | ||
|
|
||
| await store.put_many(collection="test", keys=keys, values=values) | ||
|
|
||
| # Check underlying store - all should be encrypted | ||
| for key in keys: | ||
| raw_value = await memory_store.get(collection="test", key=key) | ||
| assert raw_value is not None | ||
| assert "__encrypted_data__" in raw_value | ||
|
|
||
| # Retrieve through wrapper | ||
| results = await store.get_many(collection="test", keys=keys) | ||
| assert results == values | ||
|
|
||
| async def test_decryption_handles_unencrypted_data( | ||
| self, store: KeyringEncryptionWrapper, memory_store: MemoryStore, patched_keyring: MockKeyring | ||
| ): | ||
| """Test that unencrypted data is returned as-is.""" | ||
| # Store unencrypted data directly in underlying store | ||
| unencrypted_value = {"test": "value"} | ||
| await memory_store.put(collection="test", key="test", value=unencrypted_value) | ||
|
|
||
| # Should return as-is when retrieved through encryption wrapper | ||
| result = await store.get(collection="test", key="test") | ||
| assert result == unencrypted_value | ||
|
|
||
| async def test_decryption_handles_corrupted_data( | ||
| self, store: KeyringEncryptionWrapper, memory_store: MemoryStore, patched_keyring: MockKeyring | ||
| ): | ||
| """Test that corrupted encrypted data is handled gracefully.""" | ||
| # Store corrupted encrypted data | ||
| corrupted_value = { | ||
| "__encrypted_data__": "invalid-encrypted-data!!!", | ||
| "__encryption_version__": 1, | ||
| } | ||
| await memory_store.put(collection="test", key="test", value=corrupted_value) | ||
|
|
||
| with pytest.raises(DecryptionError): | ||
| await store.get(collection="test", key="test") | ||
|
|
||
| async def test_decryption_ignores_corrupted_data(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that corrupted encrypted data is ignored when configured.""" | ||
| store = KeyringEncryptionWrapper(key_value=memory_store, raise_on_decryption_error=False) | ||
|
|
||
| # Store corrupted encrypted data | ||
| corrupted_value = { | ||
| "__encrypted_data__": "invalid-encrypted-data!!!", | ||
| "__encryption_version__": 1, | ||
| } | ||
| await memory_store.put(collection="test", key="test", value=corrupted_value) | ||
|
|
||
| assert await store.get(collection="test", key="test") is None | ||
|
|
||
| async def test_key_rotation_with_old_keys(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that key rotation works with old_keys parameter.""" | ||
| # Create first wrapper and store data | ||
| wrapper1 = KeyringEncryptionWrapper(key_value=memory_store, key_name="old-key") | ||
| original_value = {"test": "value"} | ||
| await wrapper1.put(collection="test", key="test", value=original_value) | ||
|
|
||
| # Get the old key | ||
| old_key = patched_keyring.get_password(service_name=DEFAULT_SERVICE_NAME, username="old-key") | ||
| assert old_key is not None | ||
|
|
||
| # Create new wrapper with different key name but providing old key for rotation | ||
| wrapper2 = KeyringEncryptionWrapper(key_value=memory_store, key_name="new-key", old_keys=[old_key.encode("ascii")]) | ||
|
|
||
| # Should be able to decrypt data encrypted with old key | ||
| result = await wrapper2.get(collection="test", key="test") | ||
| assert result == original_value | ||
|
|
||
| async def test_decryption_with_wrong_key_raises_error(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that decryption with the wrong key raises an error.""" | ||
| # Create two wrappers with different keys | ||
| wrapper1 = KeyringEncryptionWrapper(key_value=memory_store, key_name="key1") | ||
| wrapper2 = KeyringEncryptionWrapper(key_value=memory_store, key_name="key2") | ||
|
|
||
| original_value = {"test": "value"} | ||
| await wrapper1.put(collection="test", key="test", value=original_value) | ||
|
|
||
| with pytest.raises(DecryptionError): | ||
| await wrapper2.get(collection="test", key="test") | ||
|
|
||
| async def test_different_service_names_are_isolated(self, memory_store: MemoryStore, patched_keyring: MockKeyring): | ||
| """Test that different service names produce different keys and are isolated.""" | ||
| wrapper1 = KeyringEncryptionWrapper(key_value=memory_store, service_name="service1") | ||
| wrapper2 = KeyringEncryptionWrapper(key_value=memory_store, service_name="service2") | ||
|
|
||
| original_value = {"test": "value"} | ||
| await wrapper1.put(collection="test", key="test", value=original_value) | ||
|
|
||
| # Different service name means different key, should fail to decrypt | ||
| with pytest.raises(DecryptionError): | ||
| await wrapper2.get(collection="test", key="test") | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid probabilistic ciphertext substring assertions.
Line 67–69 checks that
"test"/"value"are not substrings of the ciphertext; encrypted base64 may rarely contain those sequences, causing flaky tests. The encryption metadata assertions already prove encryption.✅ Safer adjustment
📝 Committable suggestion
🤖 Prompt for AI Agents