""" Encryption utilities for securing sensitive settings in the database. Uses Fernet symmetric encryption with a key derived from SESSION_SECRET. This provides encryption at rest for sensitive configuration values. """ import base64 import hashlib import logging from typing import Optional logger = logging.getLogger(__name__) # Lazy-load cryptography to avoid import errors if not installed _cipher_suite = None def _get_cipher_suite() -> object | None: """ Get or create the Fernet cipher suite for encryption/decryption. The encryption key is derived from SESSION_SECRET to ensure: 1. Settings are encrypted at rest in the database 2. The same key is used across app restarts 3. No additional secret management needed Returns: Fernet cipher suite instance """ global _cipher_suite if _cipher_suite is None: try: from cryptography.fernet import Fernet from app.config import settings # Derive a Fernet-compatible key from SESSION_SECRET # Fernet requires a 32-byte base64-encoded key secret = settings.session_secret.encode("utf-8") # Use SHA256 to get exactly 32 bytes, then base64 encode key_bytes = hashlib.sha256(secret).digest() fernet_key = base64.urlsafe_b64encode(key_bytes) _cipher_suite = Fernet(fernet_key) logger.debug("Encryption cipher suite initialized") except ImportError: logger.warning( "cryptography library not installed. " "Sensitive settings will be stored in plaintext. " "Install with: pip install cryptography" ) _cipher_suite = None except Exception as e: logger.error(f"Failed to initialize encryption: {e}") _cipher_suite = None return _cipher_suite def encrypt_value(plaintext: Optional[str]) -> Optional[str]: """ Encrypt a plaintext value for storage in the database. Args: plaintext: The value to encrypt (or None) Returns: Encrypted value as base64 string, or plaintext if encryption unavailable """ if plaintext is None or plaintext == "": return plaintext cipher = _get_cipher_suite() if cipher is None: # Encryption not available, store in plaintext with warning logger.warning("Storing sensitive value in plaintext (encryption unavailable)") return plaintext try: encrypted_bytes = cipher.encrypt(plaintext.encode("utf-8")) # Prefix with "enc:" to identify encrypted values return "enc:" + encrypted_bytes.decode("utf-8") except Exception as e: logger.error(f"Encryption failed: {e}") # Fall back to plaintext return plaintext def decrypt_value(ciphertext: Optional[str]) -> Optional[str]: """ Decrypt a value from the database. Args: ciphertext: The encrypted value (or plaintext if not encrypted) Returns: Decrypted plaintext value """ if ciphertext is None or ciphertext == "": return ciphertext # Check if value is encrypted (has "enc:" prefix) if not ciphertext.startswith("enc:"): # Not encrypted, return as-is return ciphertext cipher = _get_cipher_suite() if cipher is None: logger.error("Cannot decrypt value: encryption not available") return "[ENCRYPTED - Cannot decrypt]" try: # Remove "enc:" prefix and decrypt encrypted_bytes = ciphertext[4:].encode("utf-8") plaintext_bytes = cipher.decrypt(encrypted_bytes) return plaintext_bytes.decode("utf-8") except Exception as e: logger.error(f"Decryption failed: {e}") return "[DECRYPTION FAILED]" def is_encrypted(value: Optional[str]) -> bool: """ Check if a value is encrypted. Args: value: The value to check Returns: True if the value is encrypted, False otherwise """ return value is not None and isinstance(value, str) and value.startswith("enc:") def is_encryption_available() -> bool: """ Check if encryption is available. Returns: True if cryptography library is installed and encryption is working """ return _get_cipher_suite() is not None