""" Encryption utilities for ClaudeTools. This module provides secure encryption and decryption functions for sensitive data such as credentials, passwords, and API keys. It uses Fernet symmetric encryption which implements AES-128-CBC with HMAC authentication for data integrity. Security considerations: - Uses authenticated encryption (Fernet) to prevent tampering - Encryption key is loaded from environment configuration - All encrypted data is base64-encoded for safe storage - Decrypted values are never logged - Proper error handling for invalid keys or corrupted data """ import base64 import logging from typing import Optional from cryptography.fernet import Fernet, InvalidToken from api.config import get_settings logger = logging.getLogger(__name__) def _get_fernet_key() -> bytes: """ Get and validate the Fernet encryption key from configuration. The ENCRYPTION_KEY must be a 32-byte (256-bit) key encoded as hex. This function converts it to the base64-encoded format required by Fernet. Returns: bytes: Base64-encoded Fernet key Raises: ValueError: If the encryption key is invalid or incorrectly formatted Note: Fernet requires a 32-byte key that's base64-encoded. We store the key as hex in the config and convert it here. """ settings = get_settings() try: # Decode hex key from config raw_key = bytes.fromhex(settings.ENCRYPTION_KEY) # Validate key length (must be 32 bytes for AES-256) if len(raw_key) != 32: raise ValueError( f"Encryption key must be 32 bytes, got {len(raw_key)} bytes" ) # Convert to base64 format required by Fernet fernet_key = base64.urlsafe_b64encode(raw_key) return fernet_key except ValueError as e: logger.error("Invalid encryption key format in configuration") raise ValueError( f"Invalid encryption key: {str(e)}. " "Key must be a 64-character hex string (32 bytes)" ) from e def encrypt_string(plaintext: str) -> str: """ Encrypt a string using Fernet symmetric encryption. This function encrypts sensitive data such as passwords, API keys, and credentials for secure storage. The encrypted output is base64-encoded and can be safely stored in databases or configuration files. Args: plaintext: The string to encrypt Returns: str: Base64-encoded encrypted string Raises: ValueError: If the encryption key is invalid TypeError: If plaintext is not a string Example: ```python from api.utils.crypto import encrypt_string api_key = "sk-1234567890abcdef" encrypted = encrypt_string(api_key) # Store encrypted value in database ``` Security notes: - Uses Fernet (AES-128-CBC + HMAC) - Includes authentication tag to prevent tampering - Adds timestamp for optional TTL validation - Each encryption produces different output (uses random IV) """ if not isinstance(plaintext, str): raise TypeError(f"plaintext must be a string, got {type(plaintext)}") try: # Get Fernet cipher instance fernet_key = _get_fernet_key() cipher = Fernet(fernet_key) # Encrypt the plaintext (Fernet handles encoding internally) plaintext_bytes = plaintext.encode('utf-8') encrypted_bytes = cipher.encrypt(plaintext_bytes) # Return as string (already base64-encoded by Fernet) return encrypted_bytes.decode('ascii') except Exception as e: logger.error(f"Encryption failed: {type(e).__name__}") raise ValueError(f"Failed to encrypt data: {str(e)}") from e def decrypt_string(ciphertext: str, default: Optional[str] = None) -> str: """ Decrypt a Fernet-encrypted string back to plaintext. This function decrypts data that was encrypted using encrypt_string(). It validates the authentication tag to ensure the data hasn't been tampered with. Args: ciphertext: Base64-encoded encrypted string from encrypt_string() default: Optional default value to return if decryption fails. If None, raises an exception on failure. Returns: str: Decrypted plaintext string Raises: ValueError: If ciphertext is invalid or decryption fails (when default=None) TypeError: If ciphertext is not a string Example: ```python from api.utils.crypto import decrypt_string encrypted = "gAAAAABf..." # From database api_key = decrypt_string(encrypted) # Use decrypted api_key ``` With error handling: ```python # Return empty string if decryption fails api_key = decrypt_string(encrypted, default="") ``` Security notes: - Validates HMAC authentication tag - Prevents timing attacks through constant-time comparison - Decrypted values are never logged - Fails safely on tampered or corrupted data """ if not isinstance(ciphertext, str): raise TypeError(f"ciphertext must be a string, got {type(ciphertext)}") try: # Get Fernet cipher instance fernet_key = _get_fernet_key() cipher = Fernet(fernet_key) # Decrypt the ciphertext ciphertext_bytes = ciphertext.encode('ascii') decrypted_bytes = cipher.decrypt(ciphertext_bytes) # Return as string return decrypted_bytes.decode('utf-8') except InvalidToken as e: # Data was tampered with or encrypted with different key logger.warning("Decryption failed: Invalid token or corrupted data") if default is not None: return default raise ValueError( "Failed to decrypt data: invalid ciphertext or wrong encryption key" ) from e except Exception as e: logger.error(f"Decryption failed: {type(e).__name__}") if default is not None: return default raise ValueError(f"Failed to decrypt data: {str(e)}") from e def generate_encryption_key() -> str: """ Generate a new random encryption key for use with this module. This is a utility function for initial setup or key rotation. The generated key should be stored in the ENCRYPTION_KEY environment variable or .env file. Returns: str: 64-character hex string representing a 32-byte key Example: ```python from api.utils.crypto import generate_encryption_key new_key = generate_encryption_key() print(f"ENCRYPTION_KEY={new_key}") # Add to .env file ``` Warning: - Only use this during initial setup or key rotation - Never rotate keys without migrating existing encrypted data - Store the key securely (environment variables, secrets manager) - Never commit keys to version control """ # Generate 32 random bytes raw_key = Fernet.generate_key() # Decode from base64 to get raw bytes, then encode as hex key_bytes = base64.urlsafe_b64decode(raw_key) hex_key = key_bytes.hex() return hex_key