#!/usr/bin/env python3 """ scripts/rotar_fernet_key.py — Rotación segura de FERNET_KEY. Qué hace: 1. Lee la FERNET_KEY actual del .env 2. Pide (o genera) la nueva FERNET_KEY 3. Para cada tenant en la DB: descifra credentials_enc con la clave vieja, re-cifra con la nueva, actualiza la DB 4. Re-cifra PJ_USUARIO_ENC y PJ_PASSWORD_ENC del .env (tenant principal) 5. Actualiza FERNET_KEY en .env 6. Hace backup del .env original como .env.bak antes de cualquier escritura Nunca escribe credenciales en texto plano a disco. Si algo falla a mitad, el .env.bak permite restaurar el estado anterior. Uso: python scripts/rotar_fernet_key.py # genera nueva clave aleatoria python scripts/rotar_fernet_key.py --nueva-key # usa la clave que vos proveés """ from __future__ import annotations import argparse import asyncio import re import shutil import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) from cryptography.fernet import Fernet, InvalidToken def _cargar_env(env_path: Path) -> str: return env_path.read_text(encoding="utf-8") def _leer_campo_env(contenido: str, campo: str) -> str: m = re.search(rf"^{campo}=(.+)$", contenido, re.MULTILINE) if not m or not m.group(1).strip(): return "" return m.group(1).strip() def _reemplazar_campo_env(contenido: str, campo: str, valor: str) -> str: return re.sub( rf"^{campo}=.*$", f"{campo}={valor}", contenido, flags=re.MULTILINE, ) def _validar_fernet_key(key: str) -> bool: try: Fernet(key.encode()) return True except Exception: return False def _recifrar_valor_global(valor_enc: str, fernet_viejo: Fernet, fernet_nuevo: Fernet) -> str: """Re-cifra un valor cifrado con la Fernet global (sin derivación por tenant).""" texto = fernet_viejo.decrypt(valor_enc.encode()) return fernet_nuevo.encrypt(texto).decode() async def main() -> None: parser = argparse.ArgumentParser(description="Rotación segura de FERNET_KEY") parser.add_argument("--nueva-key", help="Nueva FERNET_KEY (default: generar aleatoriamente)") args = parser.parse_args() env_path = Path(".env") if not env_path.exists(): print("ERROR: No encontré .env. Ejecutá desde el directorio raíz del proyecto.") sys.exit(1) env_contenido = _cargar_env(env_path) # ── Leer clave vieja ────────────────────────────────────────────────────── fernet_key_vieja = _leer_campo_env(env_contenido, "FERNET_KEY") if not fernet_key_vieja: print("ERROR: FERNET_KEY no encontrada o vacía en .env") sys.exit(1) if not _validar_fernet_key(fernet_key_vieja): print("ERROR: FERNET_KEY actual es inválida.") sys.exit(1) fernet_viejo = Fernet(fernet_key_vieja.encode()) # ── Nueva clave ─────────────────────────────────────────────────────────── if args.nueva_key: fernet_key_nueva = args.nueva_key.strip() if not _validar_fernet_key(fernet_key_nueva): print("ERROR: La nueva FERNET_KEY provista es inválida.") sys.exit(1) else: fernet_key_nueva = Fernet.generate_key().decode() if fernet_key_nueva == fernet_key_vieja: print("ERROR: La nueva clave es idéntica a la actual. No hay nada que rotar.") sys.exit(1) fernet_nuevo = Fernet(fernet_key_nueva.encode()) print() print("=== Rotación de FERNET_KEY ===") print() print(f" Nueva clave: {fernet_key_nueva}") print() print(" IMPORTANTE: Guardá esta clave en un lugar seguro AHORA.") print(" Si la perdés, las credenciales de todos los tenants quedan irrecuperables.") print() confirmar = input(" Escribí 'rotar' para confirmar: ").strip() if confirmar != "rotar": print(" Operación cancelada.") sys.exit(0) # ── Backup del .env ─────────────────────────────────────────────────────── bak_path = env_path.with_suffix(".bak") shutil.copy2(env_path, bak_path) print(f"\n Backup guardado en {bak_path}") # ── Conectar a la DB y re-cifrar tenants ────────────────────────────────── from config import get_settings from database import init_db, get_conn, list_active_tenants import vault settings = get_settings() await init_db(settings.db_path) conn = get_conn() # Traer TODOS los tenants (activos y suspendidos) async with conn.execute("SELECT chat_id, credentials_enc FROM tenants WHERE credentials_enc IS NOT NULL") as cur: tenants = await cur.fetchall() print(f"\n Tenants a procesar: {len(tenants)}") errores: list[tuple[int, str]] = [] actualizaciones: list[tuple[str, int]] = [] for row in tenants: chat_id: int = row["chat_id"] credentials_enc: str = row["credentials_enc"] try: # Descifrar con clave vieja usuario, password = vault.descifrar_credenciales(chat_id, credentials_enc, fernet_key_vieja) # Re-cifrar con clave nueva nuevo_enc = vault.cifrar_credenciales(chat_id, usuario, password, fernet_key_nueva) actualizaciones.append((nuevo_enc, chat_id)) # Limpiar de memoria del usuario, password except (ValueError, InvalidToken) as e: errores.append((chat_id, str(e))) print(f" ERROR descifrar tenant {chat_id}: {e}") if errores: print(f"\n {len(errores)} tenants no pudieron descifrarse. Abortando sin cambios.") print(" El .env original no fue modificado. Revisá el .env.bak para restaurar.") sys.exit(1) # Escribir todos los cambios en la DB de una sola vez (transacción) for nuevo_enc, chat_id in actualizaciones: await conn.execute( "UPDATE tenants SET credentials_enc = ? WHERE chat_id = ?", (nuevo_enc, chat_id), ) await conn.commit() print(f" {len(actualizaciones)} tenants re-cifrados en la DB.") # ── Re-cifrar PJ_USUARIO_ENC y PJ_PASSWORD_ENC del .env ────────────────── pj_usuario_enc = _leer_campo_env(env_contenido, "PJ_USUARIO_ENC") pj_password_enc = _leer_campo_env(env_contenido, "PJ_PASSWORD_ENC") if pj_usuario_enc and pj_password_enc: try: nuevo_usuario_enc = _recifrar_valor_global(pj_usuario_enc, fernet_viejo, fernet_nuevo) nuevo_password_enc = _recifrar_valor_global(pj_password_enc, fernet_viejo, fernet_nuevo) env_contenido = _reemplazar_campo_env(env_contenido, "PJ_USUARIO_ENC", nuevo_usuario_enc) env_contenido = _reemplazar_campo_env(env_contenido, "PJ_PASSWORD_ENC", nuevo_password_enc) print(" PJ_USUARIO_ENC y PJ_PASSWORD_ENC re-cifrados en .env.") except InvalidToken: print(" ERROR: No se pudieron descifrar PJ_USUARIO_ENC/PJ_PASSWORD_ENC con la clave actual.") print(" La DB ya fue actualizada. Actualizá .env manualmente con la nueva clave") print(" y volvé a correr: python scripts/cifrar_credenciales.py") # ── Actualizar FERNET_KEY en .env ───────────────────────────────────────── env_contenido = _reemplazar_campo_env(env_contenido, "FERNET_KEY", fernet_key_nueva) env_path.write_text(env_contenido, encoding="utf-8") print() print(" ✅ Rotación completada.") print(f" Nueva clave guardada en .env") print(f" Backup del .env anterior: {bak_path}") print() print(" Próximo paso: reiniciar el bot.") print(" systemctl restart pedrito (en VPS)") print(" o Ctrl+C y uv run python main.py (en local)") print() if __name__ == "__main__": asyncio.run(main())