""" Centralized Token and API Key Encryption Utilities This module provides Fernet-based symmetric encryption for storing sensitive credentials (OAuth tokens, API keys) in the database. Centralizes encryption logic that may be scattered across models.py and other files. Features: - Fernet symmetric encryption (URL-safe base64-encoded) - Automatic key generation for development (with warning) - Key validation and rotation support - Token encryption detection (encrypted vs plaintext) - Service-specific wrapper functions for audit logging - Re-encryption support for key rotation Key Management: - Environment variable: BYOK_ENCRYPTION_KEY - Generate with: openssl rand -base64 32 - Format: 32-byte base64-encoded key (Fernet-compatible) - Cached in memory for performance Security: - All tokens encrypted at rest (never stored plaintext) - Keys never logged or exposed in error messages - Invalid tokens raise clear decryption errors - Backward compatibility with legacy plaintext tokens Usage: from core.privsec.token_encryption import encrypt_token, decrypt_token # Encrypt token before storage encrypted = encrypt_token("my-secret-token") # Store 'encrypted' in database # Decrypt when needed plaintext = decrypt_token(encrypted) """ import base64 import hashlib import logging import os from typing import Dict, Optional from cryptography.fernet import Fernet, InvalidToken from core.structured_logger import get_logger logger = get_logger(__name__) # ============================================================================ # Exception Classes # ============================================================================ class MissingKeyError(Exception): """Raised when encryption key is not configured.""" pass class InvalidKeyError(Exception): """Raised when encryption key has invalid format.""" pass class DecryptionError(Exception): """Raised when token decryption fails.""" pass # ============================================================================ # Key Management # ============================================================================ _fernet_instance: Optional[Fernet] = None _encryption_key: Optional[bytes] = None def get_encryption_key() -> bytes: """ Get encryption key from environment or generate one. Reads BYOK_ENCRYPTION_KEY from environment. If missing, generates a random key and logs a warning (development safety only). Returns: Fernet-compatible encryption key (32 bytes) Raises: InvalidKeyError: If key format is invalid """ global _encryption_key if _encryption_key is not None: return _encryption_key # Read from environment key_str = os.getenv("BYOK_ENCRYPTION_KEY") if not key_str: # Generate random key for development (NOT for production) logger.warning( "BYOK_ENCRYPTION_KEY not configured - generating temporary key. " "Set BYOK_ENCRYPTION_KEY environment variable for production use.", extra={"security_warning": True} ) key_str = generate_encryption_key() logger.info( "Generated temporary encryption key - save this for future use", extra={"generated_key": key_str, "security_note": "Save this key!"} ) # Validate key format if not validate_encryption_key(key_str): raise InvalidKeyError( "BYOK_ENCRYPTION_KEY must be a valid Fernet key " "(32 bytes base64-encoded). Generate with: openssl rand -base64 32" ) # Decode key _encryption_key = key_str.encode() return _encryption_key def validate_encryption_key(key: str) -> bool: """ Check if key is valid Fernet key format. Fernet keys are 32 bytes base64-encoded (44 characters with padding). Args: key: Key string to validate Returns: True if valid Fernet key format, False otherwise """ try: # Attempt to decode as base64 key_bytes = base64.urlsafe_b64decode(key.encode()) # Fernet keys must be 32 bytes return len(key_bytes) == 32 except Exception: return False def generate_encryption_key() -> str: """ Generate new Fernet-compatible encryption key. Returns: Base64-encoded 32-byte key suitable for Fernet Example: >>> key = generate_encryption_key() >>> # Set as environment variable: >>> # export BYOK_ENCRYPTION_KEY= """ key = Fernet.generate_key() return key.decode() def _get_fernet() -> Fernet: """ Get or create Fernet instance with cached key. Returns: Fernet instance initialized with encryption key """ global _fernet_instance if _fernet_instance is None: key = get_encryption_key() _fernet_instance = Fernet(key) return _fernet_instance def reset_fernet_cache(): """ Reset Fernet cache (mainly for testing). Forces re-reading of encryption key on next access. """ global _fernet_instance, _encryption_key _fernet_instance = None _encryption_key = None logger.debug("Fernet cache reset") # ============================================================================ # Token Encryption/Decryption # ============================================================================ def encrypt_token( plaintext: str, key: Optional[str] = None ) -> str: """ Encrypt token string using Fernet symmetric encryption. Args: plaintext: Token string to encrypt key: Optional encryption key (defaults to BYOK_ENCRYPTION_KEY) Returns: URL-safe base64-encoded ciphertext Raises: InvalidKeyError: If key format is invalid """ if not plaintext: return "" # Use provided key or get from environment if key: if not validate_encryption_key(key): raise InvalidKeyError("Provided key has invalid format") f = Fernet(key.encode()) else: f = _get_fernet() # Encrypt and return as string ciphertext = f.encrypt(plaintext.encode()) return ciphertext.decode() def decrypt_token( ciphertext: str, key: Optional[str] = None, allow_plaintext: bool = True ) -> str: """ Decrypt token string using Fernet symmetric encryption. Supports backward compatibility with plaintext tokens (optional). Args: ciphertext: Encrypted token string (or plaintext if allow_plaintext) key: Optional encryption key (defaults to BYOK_ENCRYPTION_KEY) allow_plaintext: If True, return plaintext tokens as-is (backward compat) Returns: Decrypted plaintext token Raises: DecryptionError: If decryption fails and allow_plaintext=False """ if not ciphertext: return "" # Check if value looks like Fernet ciphertext # Fernet output is base64-encoded and starts with 'gAAAA' is_encrypted = is_encrypted_value(ciphertext) if not is_encrypted and allow_plaintext: # Assume plaintext (backward compatibility) logger.debug( "Decrypting plaintext token (backward compatibility)", extra={"backward_compat": True} ) return ciphertext try: # Use provided key or get from environment if key: if not validate_encryption_key(key): raise InvalidKeyError("Provided key has invalid format") f = Fernet(key.encode()) else: f = _get_fernet() # Decrypt and return plaintext = f.decrypt(ciphertext.encode()) return plaintext.decode() except InvalidToken as e: logger.error( "Token decryption failed", extra={ "error": str(e), "ciphertext_prefix": ciphertext[:20] if ciphertext else None } ) raise DecryptionError( f"Failed to decrypt token - may be corrupted or encrypted with different key" ) from e # ============================================================================ # API Key Encryption (Service-Specific Wrappers) # ============================================================================ def encrypt_api_key(api_key: str, service: str) -> str: """ Encrypt API key with service-specific logging. Wrapper around encrypt_token that logs the encryption event for audit purposes (without logging the key value). Args: api_key: API key to encrypt service: Service name (e.g., "spotify", "openai") Returns: Encrypted API key (ciphertext) """ if not api_key: logger.warning( "Attempted to encrypt empty API key", extra={"service": service} ) return "" encrypted = encrypt_token(api_key) logger.info( "API key encrypted", extra={ "service": service, "key_length": len(api_key), "encrypted": True } ) return encrypted def decrypt_api_key(encrypted_key: str, service: str) -> str: """ Decrypt API key with service-specific logging. Wrapper around decrypt_token that logs the decryption event for audit purposes (without logging the key value). Args: encrypted_key: Encrypted API key service: Service name (e.g., "spotify", "openai") Returns: Decrypted API key (plaintext) """ if not encrypted_key: logger.warning( "Attempted to decrypt empty API key", extra={"service": service} ) return "" decrypted = decrypt_token(encrypted_key, allow_plaintext=False) logger.info( "API key decrypted", extra={ "service": service, "key_length": len(decrypted), "decrypted": True } ) return decrypted # ============================================================================ # Token Rotation Support # ============================================================================ def rotate_tokens( old_key: str, new_key: str, tokens: Dict[str, str] ) -> Dict[str, int]: """ Re-encrypt all tokens with new key. Used when rotating encryption keys. Decrypts all tokens with old key and re-encrypts with new key. Args: old_key: Old encryption key new_key: New encryption key tokens: Dict mapping token_id -> encrypted_token Returns: Dict with rotation statistics: { "total": int, "rotated": int, "failed": int, "failed_ids": List[str] } """ stats = { "total": len(tokens), "rotated": 0, "failed": 0, "failed_ids": [] } logger.info( "Starting token rotation", extra={ "total_tokens": stats["total"], "old_key_hash": hashlib.sha256(old_key.encode()).hexdigest()[:16], "new_key_hash": hashlib.sha256(new_key.encode()).hexdigest()[:16] } ) for token_id, encrypted_token in tokens.items(): try: # Decrypt with old key plaintext = decrypt_token(encrypted_token, key=old_key, allow_plaintext=False) # Re-encrypt with new key reencrypted = encrypt_token(plaintext, key=new_key) # Update in-place (caller must save to database) tokens[token_id] = reencrypted stats["rotated"] += 1 except Exception as e: logger.error( "Token rotation failed", extra={ "token_id": token_id, "error": str(e) } ) stats["failed"] += 1 stats["failed_ids"].append(token_id) logger.info( "Token rotation complete", extra=stats ) return stats # ============================================================================ # Utility Functions # ============================================================================ def is_encrypted_value(value: str) -> bool: """ Check if value looks like Fernet ciphertext. Fernet ciphertext is base64-encoded and typically starts with 'gAAAA' (the base64 encoding of the version byte and timestamp). Args: value: String to check Returns: True if value appears to be Fernet-encrypted, False otherwise """ if not value: return False # Fernet output is base64-encoded and at least 44 characters if len(value) < 44: return False # Fernet ciphertext starts with 'gAAAA' (version + timestamp prefix) if not value.startswith('gAAAA'): return False # Try to decode as base64 to confirm try: decoded = base64.urlsafe_b64decode(value.encode()) # Fernet tokens are at least 1 byte (version) + 8 bytes (timestamp) + ... return len(decoded) >= 9 except Exception: return False def hash_token(token: str) -> str: """ Hash token for comparison without storing plaintext. Uses SHA-256 to create one-way hash of token. Useful for checking if token matches without storing the actual value. Args: token: Token string to hash Returns: Hexadecimal hash string Example: >>> stored_hash = hash_token("my-token") >>> if hash_token(user_input) == stored_hash: ... print("Token matches") """ return hashlib.sha256(token.encode()).hexdigest()