"""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")