""" MFA Encryption Utilities — CrowData. Cifra/descifra secretos TOTP y códigos de respaldo usando Fernet (AES-128-GCM). La clave se deriva de MFA_ENCRYPTION_KEY (base64, 32 bytes) en settings. Soporte de versiones: - v2: Fernet con MFA_ENCRYPTION_KEY (actual, recomendado) - v1: PBKDF2 derivado de SECRET_KEY (legacy, solo lectura para migración) Formato v1: `v1:` Formato v2: `v2:` """ from cryptography.fernet import Fernet, InvalidToken from app.config import get_settings import base64 import json import logging logger = logging.getLogger(__name__) _settings = None # ─── v2: Fernet con MFA_ENCRYPTION_KEY ─── def _get_fernet_v2() -> Fernet: global _settings if _settings is None: _settings = get_settings() key = _settings.mfa_encryption_key if not key: raise RuntimeError("MFA_ENCRYPTION_KEY no configurado en .env") try: decoded = base64.urlsafe_b64decode(key) if len(decoded) != 32: raise ValueError except Exception: raise RuntimeError("MFA_ENCRYPTION_KEY debe ser base64 url-safe de 32 bytes (generar con: python -c \"import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())\")") return Fernet(key.encode()) # ─── v1: PBKDF2 derivado de SECRET_KEY (legacy, solo lectura) ─── _v1_fernet = None def _get_fernet_v1() -> Fernet: global _v1_fernet if _v1_fernet is None: s = get_settings() from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=b'crowdata-mfa-encryption', iterations=100000, ) key = base64.urlsafe_b64encode(kdf.derive(s.secret_key.encode())) _v1_fernet = Fernet(key) return _v1_fernet # ─── API pública versionada ─── VERSION_PREFIX_V1 = b"v1:" VERSION_PREFIX_V2 = b"v2:" def _encrypt_with_version(data: str, version: int = 2) -> str: """Cifra con prefijo de versión.""" if version == 1: f = _get_fernet_v1() prefix = VERSION_PREFIX_V1 else: f = _get_fernet_v2() prefix = VERSION_PREFIX_V2 return (prefix + f.encrypt(data.encode())).decode() def _decrypt_with_version(encrypted: str) -> str: """Descifra detectando automáticamente la versión por prefijo.""" if not encrypted: raise ValueError("Empty encrypted data") data_bytes = encrypted.encode() if data_bytes.startswith(VERSION_PREFIX_V1): f = _get_fernet_v1() return f.decrypt(data_bytes[len(VERSION_PREFIX_V1):]).decode() elif data_bytes.startswith(VERSION_PREFIX_V2): f = _get_fernet_v2() return f.decrypt(data_bytes[len(VERSION_PREFIX_V2):]).decode() else: # Sin prefijo = legacy sin versión, intentar v1 (formato anterior) logger.warning("Encrypted data without version prefix, trying v1 (legacy)") try: return _get_fernet_v1().decrypt(data_bytes).decode() except InvalidToken: # Último intento con v2 return _get_fernet_v2().decrypt(data_bytes).decode() # ─── API pública (usa v2 para escribir, ambas para leer) ─── def encrypt_mfa(data: str) -> str: """Cifra un string (secreto TOTP) usando v2.""" return _encrypt_with_version(data, version=2) def decrypt_mfa(encrypted: str) -> str: """Descifra detectando versión automáticamente (v1 o v2).""" return _decrypt_with_version(encrypted) def encrypt_backup_codes(codes: list[str]) -> str: """Cifra una lista de códigos de respaldo como JSON usando v2.""" import json return encrypt_mfa(json.dumps(codes)) def decrypt_backup_codes(encrypted: str) -> list[str]: """Descifra y parsea la lista de códigos de respaldo.""" import json return json.loads(decrypt_mfa(encrypted)) # ─── Migración / compatibilidad ─── def is_encrypted(data: str) -> bool: """Detecta si un string parece estar cifrado (tiene prefijo v1: o v2: o parece Fernet).""" try: if data.startswith(("v1:", "v2:")): return True # Legacy sin prefijo return len(data) > 40 and data.endswith("=") except Exception: return False def safe_decrypt_mfa(data: str) -> str: """Descifra si está cifrado, sino retorna tal cual (para migración gradual).""" if not data: return data if is_encrypted(data): try: return decrypt_mfa(data) except Exception as e: logger.warning(f"Failed to decrypt MFA data: {e}") return data # Fallback return data def safe_decrypt_backup_codes(data: str) -> list[str]: """Descifra códigos de respaldo si están cifrados, sino parsea JSON o lista vacía.""" if not data: return [] if is_encrypted(data): try: return decrypt_backup_codes(data) except Exception: pass # Fallback: intentar parsear como JSON import json try: return json.loads(data) except Exception: return [] def migrate_to_v2(encrypted_v1: str) -> str: """Re-cifra dato v1 a v2. Para migración batch.""" plaintext = _get_fernet_v1().decrypt(encrypted_v1.encode()).decode() return encrypt_mfa(plaintext)