| """ |
| 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__) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| _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 |
|
|
| |
| key_str = os.getenv("BYOK_ENCRYPTION_KEY") |
|
|
| if not key_str: |
| |
| 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!"} |
| ) |
|
|
| |
| 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" |
| ) |
|
|
| |
| _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: |
| |
| key_bytes = base64.urlsafe_b64decode(key.encode()) |
| |
| 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> |
| """ |
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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 "" |
|
|
| |
| if key: |
| if not validate_encryption_key(key): |
| raise InvalidKeyError("Provided key has invalid format") |
| f = Fernet(key.encode()) |
| else: |
| f = _get_fernet() |
|
|
| |
| 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 "" |
|
|
| |
| |
| is_encrypted = is_encrypted_value(ciphertext) |
|
|
| if not is_encrypted and allow_plaintext: |
| |
| logger.debug( |
| "Decrypting plaintext token (backward compatibility)", |
| extra={"backward_compat": True} |
| ) |
| return ciphertext |
|
|
| try: |
| |
| if key: |
| if not validate_encryption_key(key): |
| raise InvalidKeyError("Provided key has invalid format") |
| f = Fernet(key.encode()) |
| else: |
| f = _get_fernet() |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| plaintext = decrypt_token(encrypted_token, key=old_key, allow_plaintext=False) |
|
|
| |
| reencrypted = encrypt_token(plaintext, key=new_key) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| if len(value) < 44: |
| return False |
|
|
| |
| if not value.startswith('gAAAA'): |
| return False |
|
|
| |
| try: |
| decoded = base64.urlsafe_b64decode(value.encode()) |
| |
| 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() |
|
|