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
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ RedisDict is a Python library that offers a convenient and familiar interface fo

The library includes utility functions for more complex use cases such as caching, batching, and more. By leveraging Redis for efficient key-value storage, RedisDict enables high-performance data management, maintaining efficiency even with large datasets and Redis instances.


[Usage](#Usage) | [Types](#Types) | [Expiration](#Expiration) | [Batching](#Batching) | [Custom Types](#Custom-Types) | [Security](#Security)

---

## Features

* Dictionary-like interface: Use familiar Python dictionary syntax to interact with Redis.
Expand All @@ -22,7 +27,7 @@ The library includes utility functions for more complex use cases such as cachin
* Custom data: types: Add custom types encoding/decoding to store your data types.
* Encryption: allows for storing data encrypted, while retaining the simple dictionary interface.

## Example
## Usage

```bash
pip install redis-dict
Expand Down Expand Up @@ -277,7 +282,7 @@ print(dic)
```

### Additional Examples
For more advanced examples of RedisDict, please refer to the unit-test files in the repository. All features and functionalities are thoroughly tested in [unit tests (here)](https://github.com/Attumm/redis-dict/blob/main/tests/unit/tests.py#L1) Or take a look at load test for batching [load test](https://github.com/Attumm/redis-dict/blob/main/tests/load/load_test.py#L1).
For more advanced examples of RedisDict, please refer to the unit-test files in the repository. All features and functionalities are thoroughly tested in [unit tests (here)](https://github.com/Attumm/redis-dict/blob/main/tests/unit/tests.py#L1) Or take a look at load test for batching [load test](https://github.com/Attumm/redis-dict/blob/main/tests/load/tests_load.py#L1).
The unit-tests can be as used as a starting point.

### Nested types
Expand All @@ -302,6 +307,7 @@ encoded = json.dumps(data, cls=RedisDictJSONEncoder)
result = json.loads(encoded, cls=RedisDictJSONDecoder)
```

## Custom Types
### Extending RedisDict with Custom Types

RedisDict supports custom type serialization. Here's how to add a new type:
Expand Down Expand Up @@ -351,14 +357,22 @@ dic["3"] = "three"
assert list(dic.keys()) == ["1", "2", "3"]
```

For more information on [extending types](https://github.com/Attumm/redis-dict/blob/main/tests/unit/extend_types_tests.py).
### Redis Encryption
For more information on [extending types](https://github.com/Attumm/redis-dict/blob/main/tests/unit/tests_extend_types.py).

## Security

Security is an important aspect of production projects. Redis-dict was developed within a strict compliance environment.
Best practice in Redis is to use passwords and network encryption through TLS. However, Redis-dict offers an additional feature by using extended types.
It is possible to store the values of keys encrypted. The values are encrypted with AES GCM, which is currently considered best practice for security.

### Storage Encryption
For storing data values encrypted can be achieved using encrypted values, more documentation on that later.
For now code example within this test file. [encrypted test](https://github.com/Attumm/redis-dict/blob/main/tests/unit/tests_encrypt.py).

### Encryption Network
Setup guide for configuring and utilizing encrypted Redis TLS for redis-dict.
[Setup guide](https://github.com/Attumm/redis-dict/blob/main/docs/tutorials/encrypted_redis.MD)

### Redis Storage Encryption
For storing encrypted data values, it's possible to use extended types. Take a look at this [encrypted test](https://github.com/Attumm/redis-dict/blob/main/tests/unit/encrypt_tests.py).

### Tests
The RedisDict library includes a comprehensive suite of tests that ensure its correctness and resilience. The test suite covers various data types, edge cases, and error handling scenarios. It also employs the Hypothesis library for property-based testing, which provides fuzz testing to evaluate the implementation

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "redis-dict"
version = "3.2.2"
version = "3.2.3"
description = "Dictionary with Redis as storage backend"
authors = [
{name = "Melvin Bijman", email = "bijman.m.m@gmail.com"},
Expand Down
2 changes: 2 additions & 0 deletions scripts/serve_docs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
open docs/build/html/index.html # On macOS
#xdg-open docs/build/html/index.html # On Linux
1 change: 1 addition & 0 deletions scripts/start_redis_docker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
docker run --name my-redis -p 6379:6379 -d redis
99 changes: 75 additions & 24 deletions tests/unit/tests_encrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,58 +12,109 @@
class EncryptedStringClassBased(str):
"""A class that behaves like a string but enables encrypted storage in Redis dictionaries.

This class inherits from the built-in str class, providing all standard string
This class inherits from the built-in `str` class, providing all standard string
functionality. However, when stored in a RedisDict, it is automatically
encrypted. This allows for transparent encryption of sensitive data in Redis
without changing how the string is used in your Python code.

The class uses AES encryption with Galois/Counter Mode (GCM) for secure
encryption and decryption. It is designed for testing purposes
the use of proper encryption techniques, including initialization vectors (IV)
and nonces.
encryption and decryption.

Attributes:
nonce (bytes): A class-level attribute representing the nonce used for encryption.
iv (bytes): The initialization vector, retrieved from an environment variable.
key (bytes): The encryption key, retrieved from an environment variable.

Note:
While this class uses actual encryption for testing, in real-world applications,
more robust key management and security practices should be implemented. Never
use hardcoded nonces or store encryption keys in environment variables without
proper security measures in production environments.
"""
nonce = b"0123456789abcdef"

def __init__(self, value: str):
"""Initializes an EncryptedStringClassBased object.

Retrieves the initialization vector (IV) and encryption key from
environment variables 'ENCRYPTION_IV' and 'ENCRYPTION_KEY' respectively,
decoding them from Base64.

Args:
value (str): The string value to be encapsulated and potentially encrypted.
"""
self.value = value
self.iv = base64.b64decode(os.environ['ENCRYPTION_IV'])
self.key = base64.b64decode(os.environ['ENCRYPTION_KEY'])

def __str__(self):
"""Returns the decrypted string value.

This method is called when the object is used in a string context,
returning the original string value.

Returns:
str: The original string value.
"""
return self.value

def __repr__(self):
"""Returns a string representation of the EncryptedStringClassBased object.

This method provides a string that, when evaluated, would recreate the object.

Returns:
str: A string representation of the object, in the format
"EncryptedStringClassBased('value')".
"""
return f"EncryptedStringClassBased('{self.value}')"

def encode(self) -> str:
cipher = Cipher(algorithms.AES(self.key), modes.GCM(self.nonce), backend=default_backend())
"""Encrypts the string value using AES-GCM.

Retrieves the encryption key and initialization vector (IV) from instance attributes
(which are loaded from environment variables during initialization).
Generates a random nonce for each encryption operation.
Uses AES in GCM mode to encrypt the string value.
Combines the nonce, IV, GCM tag, and ciphertext, then Base64 encodes the result.

Returns:
str: Base64 encoded string containing the nonce, IV, GCM tag, and ciphertext.
This encoded string represents the encrypted value.
"""
key = self.key or base64.b64decode(os.environ['ENCRYPTION_KEY'])
iv = self.iv or base64.b64decode(os.environ['ENCRYPTION_IV'])

nonce = os.urandom(16)

cipher = Cipher(algorithms.AES(key), modes.GCM(nonce), backend=default_backend())
encryptor = cipher.encryptor()

encrypted_data = encryptor.update(self.value.encode('utf-8', errors='surrogatepass')) + encryptor.finalize()
return str(base64.b64encode(self.iv + self.nonce + encryptor.tag + encrypted_data).decode('utf-8'))

combined_data = nonce + iv + encryptor.tag + encrypted_data
return str(base64.b64encode(combined_data).decode('utf-8'))

@classmethod
def decode(cls, encrypted_value: str) -> 'EncryptedStringClassBased':
iv = base64.b64decode(os.environ['ENCRYPTION_IV'])
"""Decrypts an encrypted string value.

Decodes the Base64 encoded encrypted string.
Extracts the nonce, IV, GCM tag, and ciphertext from the decoded bytes.
Retrieves the encryption key and initialization vector (IV) from environment variables.
Uses AES in GCM mode with the extracted nonce and tag to decrypt the ciphertext.

Args:
encrypted_value (str): Base64 encoded string representing the encrypted value.

Returns:
EncryptedStringClassBased: A new `EncryptedStringClassBased` object containing
the decrypted string value.
"""
key = base64.b64decode(os.environ['ENCRYPTION_KEY'])
nonce = cls.nonce
iv = base64.b64decode(os.environ['ENCRYPTION_IV'])

encrypted_data_bytes = base64.b64decode(encrypted_value)

encrypted_data = base64.b64decode(encrypted_value)
tag = encrypted_data[len(iv) + len(nonce):len(iv) + len(nonce) + 16]
ciphertext = encrypted_data[len(iv) + len(nonce) + 16:]
nonce_from_storage = encrypted_data_bytes[:16]
iv_from_storage = encrypted_data_bytes[16:16 + len(iv)]
tag = encrypted_data_bytes[16 + len(iv):16 + len(iv) + 16]
ciphertext = encrypted_data_bytes[16 + len(iv) + 16:]

cipher = Cipher(algorithms.AES(key), modes.GCM(nonce, tag), backend=default_backend())
cipher = Cipher(algorithms.AES(key), modes.GCM(nonce_from_storage, tag),
backend=default_backend())
decryptor = cipher.decryptor()

decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
Expand Down Expand Up @@ -108,13 +159,13 @@ def test_encrypted_string_encoding_and_decoding(self):
iv = base64.b64decode(os.environ['ENCRYPTION_IV'])
key = base64.b64decode(os.environ['ENCRYPTION_KEY'])

encode_encrypted_function = encode_encrypted_string(iv, key, EncryptedStringClassBased.nonce)
#encode_encrypted_function = encode_encrypted_string(iv, key, EncryptedStringClassBased.nonce)

redis_dict.extends_type(EncryptedStringClassBased)
key = "foo"
expected_type = EncryptedStringClassBased.__name__
expected = "foobar"
encoded_expected = encode_encrypted_function(expected)
#encoded_expected = encode_encrypted_function(expected)

redis_dict[key] = EncryptedStringClassBased(expected)

Expand All @@ -123,11 +174,11 @@ def test_encrypted_string_encoding_and_decoding(self):
self.assertNotEqual(internal_result_value, expected)

self.assertEqual(internal_result_type, expected_type)
self.assertEqual(internal_result_value, encoded_expected)
#self.assertEqual(internal_result_value, encoded_expected)

result = redis_dict[key]

self.assertNotEqual(encoded_expected, expected)
#self.assertNotEqual(encoded_expected, expected)
self.assertIsInstance(result, EncryptedStringClassBased)
self.assertEqual(result, expected)

Expand Down
6 changes: 1 addition & 5 deletions tests/unit/tests_extend_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def helper_get_redis_internal_value(self, key):
internal_result_type, internal_result_value = stored_in_redis_as.split(sep, 1)
return internal_result_type, internal_result_value


class TestRedisDictExtendTypesDefault(BaseRedisDictTest):

def test_customer_encoding_and_decoding(self):
Expand Down Expand Up @@ -168,9 +169,6 @@ def test_person_encoding_decoding_should_remain_equal(self):
self.assertEqual(result_two.address, expected_person.address)


# Start test for simple encryption storing of data.


class EncryptedRot13String(str):
"""
A class that behaves exactly like a string but has a distinct type for redis-dict encoding and decoding.
Expand Down Expand Up @@ -557,7 +555,6 @@ def test_compression_size_reduction(self):
self.assertEqual(decoded, expected)
self.assertEqual(len(decoded), original_size)


def test_compression_timing_comparison(self):
"""Compare timing of operations between compressed and uncompressed strings"""
redis_dict = self.redis_dict # A new instance for regular strings
Expand Down Expand Up @@ -596,7 +593,6 @@ def test_compression_timing_comparison(self):
print(f"Regular string get time: {regular_get_time:.6f} seconds")



class TestNewTypeComplianceFailures(BaseRedisDictTest):
def test_missing_encode_method(self):
class MissingEncodeMethod:
Expand Down
3 changes: 0 additions & 3 deletions tests/unit/tests_standard_types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import sys
import unittest

from pathlib import Path
from uuid import UUID
from pathlib import Path
from decimal import Decimal
Expand All @@ -10,8 +9,6 @@

from redis_dict import RedisDict

import src.redis_dict.type_management

sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
from redis_dict.type_management import _default_decoder

Expand Down