""" vault.py — Cifrado de credenciales por tenant. ## Arquitectura v3: Envelope Encryption con PBKDF2 + DEK por tenant Cada credencial se cifra en dos capas independientes: 1. DEK (Data Encryption Key): 32 bytes aleatorios, única por operación de cifrado. 2. KEK→Wrapping Key: derivada de FERNET_KEY usando PBKDF2-HMAC-SHA256 con salt único por tenant. El salt se genera al cifrar y se guarda junto al ciphertext. La KEK (FERNET_KEY del .env) nunca cifra datos directamente — solo envuelve la DEK. Formato credentials_enc v3: JSON compacto: {"v":3,"s":"","k":"","p":""} s = salt de 16 bytes (base64url) para derivar la wrapping key via PBKDF2 k = DEK cifrada con Fernet(wrapping_key) p = {"usuario": ..., "password": ...} cifrado con Fernet(DEK) Ventajas sobre v1 (SHA256): - PBKDF2 (600k iter): derivar la wrapping key cuesta ~200ms — fuerza bruta inviable - Salt único por tenant: mismo FERNET_KEY + mismo password → ciphertexts distintos - DEK aleatoria: comprometer una credencial no ayuda con las demás - Dos capas: robar la DB sin FERNET_KEY es inútil; robar FERNET_KEY sin la DB también ## Formato v1 (legacy, solo lectura) Fernet(SHA256(FERNET_KEY + str(chat_id))) — mantenido para migración desde el script `scripts/migrar_vault_v3.py`. No se generan nuevos v1. """ from __future__ import annotations import base64 import hashlib import json import os from functools import lru_cache import structlog from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from utils.sanitize import scrub logger = structlog.get_logger(__name__) # PBKDF2: NIST SP 800-132 (2023) recomienda mínimo 600k iteraciones para SHA-256 _PBKDF2_ITERATIONS = 600_000 _SALT_BYTES = 16 _DEK_BYTES = 32 def _derive_wrapping_key(kek: str, salt: bytes) -> bytes: """ Deriva una wrapping key de 32 bytes usando PBKDF2-HMAC-SHA256. ~200ms intencionalmente — hace fuerza bruta inviable incluso con GPU. """ kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=_DEK_BYTES, salt=salt, iterations=_PBKDF2_ITERATIONS, ) return base64.urlsafe_b64encode(kdf.derive(kek.encode("utf-8"))) @lru_cache(maxsize=256) def _wrapping_key_cached(kek_fingerprint: str, salt_hex: str, kek: str) -> bytes: """ Cache de wrapping keys por (salt, kek) para no ejecutar PBKDF2 600k veces en el mismo proceso cuando el poller revisa múltiples tenants. kek_fingerprint es el SHA256[:8] de kek — sirve para invalidar entradas si el FERNET_KEY cambia (nueva instancia del proceso). """ return _derive_wrapping_key(kek, bytes.fromhex(salt_hex)) def _get_wrapping_key(kek: str, salt: bytes) -> bytes: """Obtiene wrapping key del cache o la deriva si no está.""" fingerprint = hashlib.sha256(kek.encode()).hexdigest()[:8] return _wrapping_key_cached(fingerprint, salt.hex(), kek) # ── API pública ─────────────────────────────────────────────────────────────── def cifrar_credenciales(chat_id: int, usuario: str, password: str, fernet_key: str) -> str: """ Cifra credenciales con envelope encryption v3. Retorna JSON compacto listo para guardar en DB. """ # Capa 1: generar salt (para PBKDF2) y DEK aleatoria salt = os.urandom(_SALT_BYTES) dek_raw = os.urandom(_DEK_BYTES) dek_b64 = base64.urlsafe_b64encode(dek_raw) # Capa 2: cifrar DEK con wrapping key derivada de FERNET_KEY + salt wrapping_key = _get_wrapping_key(fernet_key, salt) f_wrap = Fernet(wrapping_key) dek_enc = f_wrap.encrypt(dek_b64) # Capa 3: cifrar payload con DEK f_dek = Fernet(dek_b64) payload_bytes = json.dumps({"usuario": usuario, "password": password}).encode() payload_enc = f_dek.encrypt(payload_bytes) envelope = { "v": 3, "s": base64.urlsafe_b64encode(salt).decode(), "k": dek_enc.decode(), "p": payload_enc.decode(), } logger.info("credenciales_cifradas_v3", chat_id=chat_id) return json.dumps(envelope, separators=(",", ":")) def descifrar_credenciales(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]: """ Descifra credenciales. Soporta v1 (legacy) y v3 (actual). Levanta ValueError si no se puede descifrar. """ stripped = credentials_enc.strip() if stripped.startswith("{"): return _descifrar_v3(chat_id, stripped, fernet_key) return _descifrar_v1(chat_id, stripped, fernet_key) def es_formato_v3(credentials_enc: str) -> bool: """Retorna True si las credenciales ya están en formato v3.""" return credentials_enc.strip().startswith("{") # ── Implementaciones internas ───────────────────────────────────────────────── def _descifrar_v3(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]: try: envelope = json.loads(credentials_enc) if envelope.get("v") != 3: raise ValueError(f"Versión desconocida: {envelope.get('v')}") salt = base64.urlsafe_b64decode(envelope["s"]) dek_enc = envelope["k"].encode() payload_enc = envelope["p"].encode() # Descifrar DEK con wrapping key derivada de PBKDF2 wrapping_key = _get_wrapping_key(fernet_key, salt) f_wrap = Fernet(wrapping_key) dek_b64 = f_wrap.decrypt(dek_enc) # Descifrar payload con DEK f_dek = Fernet(dek_b64) payload = json.loads(f_dek.decrypt(payload_enc).decode()) return payload["usuario"], payload["password"] except (InvalidToken, KeyError, ValueError, json.JSONDecodeError) as e: logger.error("error_descifrar_v3", chat_id=chat_id, error=scrub(str(e))) raise ValueError( f"No se pudieron descifrar credenciales (v3) para chat_id={chat_id}" ) from e def _descifrar_v1(chat_id: int, credentials_enc: str, fernet_key: str) -> tuple[str, str]: """ Legacy: clave derivada con SHA256(fernet_key + chat_id). Disponible para migración — no se generan nuevas v1. """ raw = (fernet_key + str(chat_id)).encode() key = base64.urlsafe_b64encode(hashlib.sha256(raw).digest()) f = Fernet(key) try: payload = json.loads(f.decrypt(credentials_enc.encode()).decode()) logger.warning("credenciales_v1_descifradas_migracion_pendiente", chat_id=chat_id) return payload["usuario"], payload["password"] except (InvalidToken, KeyError, ValueError) as e: logger.error("error_descifrar_v1", chat_id=chat_id, error=scrub(str(e))) raise ValueError( f"No se pudieron descifrar credenciales (v1) para chat_id={chat_id}" ) from e