- utils/rate_limiter.py: ventana deslizante 20 req/60s por chat_id, aplicado en todos los handlers de telegram_bot.py - database.py: tabla invite_codes (un solo uso por código), funciones crear_invite_code() y verificar_y_consumir_invite_code() - onboarding.py: verifica primero en DB (un solo uso), fallback al INVITE_CODE del .env (ilimitado, para uso interno) - scripts/generar_codigo.py: genera 1 o N códigos de invitación - scripts/rotar_fernet_key.py: re-cifra todos los tenants y el .env con nueva FERNET_KEY, backup automático de .env antes de escribir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
8.1 KiB
Python
205 lines
8.1 KiB
Python
#!/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 <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())
|