- Add EncryptionService with AES-256-GCM via cryptography.fernet - Implement PBKDF2HMAC key derivation with SHA256 (100k iterations) - Deterministic salt derived from master_key for consistency - Methods: encrypt(), decrypt() with proper error handling - 12 comprehensive tests with 100% coverage - Handle InvalidToken, TypeError edge cases
99 lines
3.1 KiB
Python
99 lines
3.1 KiB
Python
"""Encryption service for sensitive data using AES-256-GCM.
|
|
|
|
This module provides encryption and decryption functionality using
|
|
cryptography.fernet which implements AES-256-GCM with PBKDF2HMAC
|
|
key derivation.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
|
|
|
|
class EncryptionService:
|
|
"""Service for encrypting and decrypting sensitive data.
|
|
|
|
Uses AES-256-GCM via Fernet with PBKDF2HMAC key derivation.
|
|
The salt is derived deterministically from the master key to
|
|
ensure consistent encryption/decryption across sessions.
|
|
"""
|
|
|
|
def __init__(self, master_key: str):
|
|
"""Initialize encryption service with master key.
|
|
|
|
Args:
|
|
master_key: The master encryption key. Should be at least
|
|
32 characters for security.
|
|
"""
|
|
self._fernet = self._derive_key(master_key)
|
|
|
|
def _derive_key(self, master_key: str) -> Fernet:
|
|
"""Derive Fernet key from master key using PBKDF2HMAC.
|
|
|
|
The salt is derived deterministically from the master key itself
|
|
using SHA-256. This ensures:
|
|
1. Same master key always produces same encryption key
|
|
2. No need to store salt separately
|
|
3. Different master keys produce different salts
|
|
|
|
Args:
|
|
master_key: The master encryption key.
|
|
|
|
Returns:
|
|
Fernet instance initialized with derived key.
|
|
"""
|
|
# Derive salt deterministically from master_key
|
|
# This ensures same master_key always produces same key
|
|
salt = hashlib.sha256(master_key.encode()).digest()[:16]
|
|
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
iterations=100000,
|
|
)
|
|
key = base64.urlsafe_b64encode(kdf.derive(master_key.encode()))
|
|
return Fernet(key)
|
|
|
|
def encrypt(self, plaintext: str) -> str:
|
|
"""Encrypt plaintext string.
|
|
|
|
Args:
|
|
plaintext: The string to encrypt.
|
|
|
|
Returns:
|
|
Base64-encoded ciphertext.
|
|
|
|
Raises:
|
|
TypeError: If plaintext is not a string.
|
|
"""
|
|
if not isinstance(plaintext, str):
|
|
raise TypeError("plaintext must be a string")
|
|
|
|
# Fernet.encrypt returns bytes, decode to string
|
|
ciphertext_bytes = self._fernet.encrypt(plaintext.encode("utf-8"))
|
|
return ciphertext_bytes.decode("utf-8")
|
|
|
|
def decrypt(self, ciphertext: str) -> str:
|
|
"""Decrypt ciphertext string.
|
|
|
|
Args:
|
|
ciphertext: The base64-encoded ciphertext to decrypt.
|
|
|
|
Returns:
|
|
The decrypted plaintext string.
|
|
|
|
Raises:
|
|
InvalidToken: If ciphertext is invalid or corrupted.
|
|
TypeError: If ciphertext is not a string.
|
|
"""
|
|
if not isinstance(ciphertext, str):
|
|
raise TypeError("ciphertext must be a string")
|
|
|
|
# Fernet.decrypt expects bytes
|
|
plaintext_bytes = self._fernet.decrypt(ciphertext.encode("utf-8"))
|
|
return plaintext_bytes.decode("utf-8")
|