diff --git a/CLAUDE.md b/CLAUDE.md index 55130c5..6cdef59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,16 +47,55 @@ Toda llamada a `logger.*()` con datos externos pasa por acá. ## Arquitectura +```mermaid +graph TD + ABOGADO["👤 Abogado"] + TG(["Telegram API"]) + CSJ(["apps.csj.gov.py · PJ"]) + + subgraph VPS ["VPS — Ubuntu 22.04"] + NGX["🔐 Nginx\nSSL · 30 r/s webhook · 10 r/min health\nX-Content-Type-Options · X-Frame-Options"] + + subgraph DOCKER ["🐳 Docker container"] + WH["POST /webhook\n→ 404 si token inválido\nsecrets.compare_digest"] + HL["GET /health\npúblico: status + timestamp"] + HH["GET /health/history\nBearer auth · chat_id enmascarado SHA256"] + ONB["Onboarding FSM · 8 pasos\ninvite atómico UPDATE WHERE usado=0\ntiming ≥800ms · compare_digest"] + CMD["Comandos\n/notif · /exp · /pdf · /estado · /ayuda"] + POLLER["Poller · APScheduler\nmulti-tenant · lun-vie 7-18h"] + VAULT["🔐 vault.py\nPBKDF2-SHA256 600k iter\nDEK 32B por cifrado · envelope v3"] + CFG["🔐 config.py · Settings\nenv vars borradas post-init"] + SCRUB["scrub()\ntodo dato externo a logs\npasa por acá antes de escribir"] + end + + DB[("pasante.db\ntenants · audit_log\ninvite_codes · migrations")] + SNAP["snapshot_{chat_id}.json\ndiff notificaciones por tenant"] + end + + ABOGADO <-->|"HTTPS"| TG + TG -->|"HTTPS + X-Secret-Token"| NGX + NGX --> WH + NGX --> HL + NGX --> HH + WH --> ONB + WH --> CMD + ONB -->|"cifra credenciales"| VAULT + VAULT <-->|"credentials_enc"| DB + CMD --> DB + HH -->|"audit_log — chat_id enmascarado"| DB + POLLER -->|"HTTPS + Bearer + usuario-rol:16"| CSJ + CSJ -->|"notificaciones JSON"| POLLER + POLLER --> SNAP + POLLER -->|"alertas"| TG + POLLER -->|"descifra por tenant"| VAULT + CFG -.->|"configura"| VAULT + CFG -.->|"configura"| POLLER + + classDef secure fill:#fef2f2,stroke:#991b1b,color:#450a0a + class NGX,VAULT,CFG,SCRUB secure ``` -main.py ← entrypoint, arranca bot + poller en paralelo -config.py ← Settings via pydantic-settings (ÚNICA fuente de config) -csj_client.py ← HTTP client del PJ (login + notificaciones, read-only) -poller.py ← loop de polling + diff + dispara alertas -telegram_bot.py ← envía mensajes y maneja /comandos básicos -storage.py ← snapshot en JSON local (sin DB por ahora) -utils/ - sanitize.py ← credential_scrubber() — NUNCA loggear sin pasar por acá -``` + +Ver [SECURITY.md](SECURITY.md) para el diagrama de arquitectura de seguridad detallado. --- @@ -68,11 +107,13 @@ utils/ | HTTP client | `httpx` async | | Bot | `python-telegram-bot v21` async | | Scheduler | `APScheduler 3` | -| Cifrado | `cryptography` (Fernet) | +| Cifrado | `cryptography` (Fernet + PBKDF2) | +| HTTP server | `aiohttp>=3.9` (health check + webhook) | | Config | `pydantic-settings` | | Logging | `structlog` (JSON en prod, ConsoleRenderer en dev) | +| Infraestructura | Docker + docker-compose | -Sin Playwright. Sin Postgres. Sin Redis. Sin LLM. Sin Docker por ahora (opcional). +Sin Playwright. Sin Postgres. Sin Redis. Sin LLM. **No introducir librerías fuera de esta lista sin justificación explícita.** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f9ba593 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,179 @@ +# Arquitectura de seguridad — Pedrito Hechakuaa + +Versión actual: **v0.4.1**. Ver [CHANGELOG.md](CHANGELOG.md) para historial. + +--- + +## 1. Defensa en profundidad + +Cada vector de ataque identificado tiene al menos un control en la capa de red, +uno en la aplicación, y uno en los datos. + +```mermaid +graph LR + subgraph THREATS ["Vectores de ataque"] + T1["🌐 Red pública\nport scan · DDoS · flood"] + T2["📨 Webhook spoofing\nfake Telegram updates"] + T3["🔍 Health endpoint\nexfiltración de datos internos"] + T4["🔑 Onboarding\nbrute-force de códigos de invitación"] + T5["💾 Robo de DB\ncredenciales en texto plano"] + T6["🧠 Acceso a proceso\n/proc/pid/environ · docker inspect"] + T7["👤 Usuario no registrado\nreconocimiento de comandos"] + end + + subgraph DEFENSES ["Controles implementados"] + D1["UFW: solo 22/80/443\nNginx rate-limit 30r/s webhook\n10r/min health · security headers"] + D2["X-Secret-Token\nsecrets.compare_digest\n→ 404 si falla (no confirma endpoint)"] + D3["Bearer auth en /history\nchat_id enmascarado SHA256[:8]\n/health público devuelve mínimo"] + D4["UPDATE atómico WHERE usado=0\ntiming mínimo ≥800ms\nsecrets.compare_digest vs master code"] + D5["vault.py v3: PBKDF2 + DEK/KEK\nrobar DB sin FERNET_KEY = inútil\nrobar FERNET_KEY sin DB = inútil"] + D6["os.environ.pop() post-init\nFERNET_KEY · tokens · secrets borrados\n(protege /proc · no docker inspect)"] + D7["Silencio total a desconocidos\nallowlist por chat_id\nrespuestas mínimas no revelan arquitectura"] + end + + T1 --> D1 + T2 --> D2 + T3 --> D3 + T4 --> D4 + T5 --> D5 + T6 --> D6 + T7 --> D7 + + classDef threat fill:#fef2f2,stroke:#991b1b,color:#450a0a + classDef defense fill:#f0fdf4,stroke:#166534,color:#052e16 + class T1,T2,T3,T4,T5,T6,T7 threat + class D1,D2,D3,D4,D5,D6,D7 defense +``` + +--- + +## 2. Cifrado de credenciales — vault.py v3 + +El sistema usa **envelope encryption** en dos capas. Comprometer una capa sola +no es suficiente para obtener credenciales en texto plano. + +```mermaid +graph TD + subgraph INPUT ["Entrada"] + FKEY["FERNET_KEY\n(del .env — jamás toca disco como texto)"] + CREDS["usuario + password\n(del abogado en Telegram)"] + end + + subgraph GEN ["Generación de material criptográfico"] + SALT["salt = os.urandom(16)\n16 bytes aleatorios\núnico por cada operación de cifrado"] + DEK["dek = os.urandom(32)\n32 bytes aleatorios\núnico por cada operación de cifrado"] + end + + subgraph KEK_LAYER ["Capa KEK — derivación de clave wrapping"] + PBKDF2["PBKDF2-HMAC-SHA256\n600.000 iteraciones · ~200ms\nwrapping_key = PBKDF2(FERNET_KEY, salt)\nfuerza bruta inviable incluso con GPU"] + WRAP["dek_enc = Fernet(wrapping_key).encrypt(dek)\nla DEK queda protegida por la clave derivada"] + end + + subgraph DEK_LAYER ["Capa DEK — cifrado de datos"] + PAYLOAD["payload = JSON con usuario + password"] + PENC["payload_enc = Fernet(dek).encrypt(payload)\nlos datos quedan protegidos por clave efímera"] + end + + subgraph STORED ["Guardado en DB — credentials_enc"] + JSON_OUT["{v:3, s:salt_b64, k:dek_enc_b64, p:payload_enc_b64}"] + end + + FKEY --> PBKDF2 + SALT --> PBKDF2 + PBKDF2 --> WRAP + DEK --> WRAP + DEK --> PENC + CREDS --> PAYLOAD --> PENC + WRAP --> JSON_OUT + PENC --> JSON_OUT + SALT --> JSON_OUT + + classDef input fill:#eff6ff,stroke:#1d4ed8,color:#1e3a8a + classDef crypto fill:#f0fdf4,stroke:#166534,color:#052e16 + classDef stored fill:#fefce8,stroke:#854d0e,color:#451a03 + class FKEY,CREDS input + class SALT,DEK,PBKDF2,WRAP,PAYLOAD,PENC crypto + class JSON_OUT stored +``` + +### Por qué dos capas + +| Atacante tiene acceso a... | Puede obtener credenciales? | +|----------------------------|-----------------------------| +| Solo la DB (`pasante.db`) | No — DEK y payload cifrados con clave derivada de FERNET_KEY | +| Solo `FERNET_KEY` | No — el salt está en la DB; sin él no se puede derivar la wrapping key | +| DB + `FERNET_KEY` | Sí — pero requiere acceso completo al servidor | +| Una credencial descifrada | No afecta las demás — cada tenant tiene salt y DEK únicos | + +--- + +## 3. Ciclo de vida de credenciales + +```mermaid +sequenceDiagram + actor Abogado + participant ONB as Onboarding FSM + participant BOT as telegram_bot + participant VAULT as vault.py + participant DB as pasante.db + participant POLL as Poller + participant CSJ as apps.csj.gov.py + + Note over Abogado,DB: — Alta de abogado — + + Abogado->>BOT: envía contraseña por Telegram + BOT->>BOT: delete_message() — borra el mensaje del chat + BOT->>ONB: procesa credencial + ONB->>VAULT: cifrar_credenciales(chat_id, usuario, password, fernet_key) + Note over VAULT: genera salt (16B) + DEK (32B) aleatorios + Note over VAULT: PBKDF2(FERNET_KEY, salt, 600k iter) → wrapping_key + Note over VAULT: Fernet(wrapping_key).encrypt(DEK) → dek_enc + Note over VAULT: Fernet(DEK).encrypt(payload) → payload_enc + VAULT-->>ONB: JSON envelope v3 + ONB->>DB: UPDATE tenants SET credentials_enc = ? + Note over ONB: del password — limpieza de memoria inmediata + + Note over Abogado,CSJ: — Ciclo de polling (cada hora) — + + POLL->>DB: SELECT credentials_enc FROM tenants WHERE estado='activo' + POLL->>VAULT: descifrar_credenciales(chat_id, credentials_enc, fernet_key) + Note over VAULT: lru_cache(wrapping_key) si mismo (salt, fernet_key) ya derivado + Note over VAULT: Fernet(wrapping_key).decrypt(dek_enc) → DEK + Note over VAULT: Fernet(DEK).decrypt(payload_enc) → usuario, password + VAULT-->>POLL: (usuario, password) — solo en memoria + POLL->>CSJ: POST /autenticador/login {usuario, clave} + CSJ-->>POLL: bearerToken (TTL 1h) + Note over POLL: del usuario, password — limpieza de memoria + POLL->>CSJ: GET /Notificaciones/PorRecibir (Bearer) + CSJ-->>POLL: lista de notificaciones + POLL->>Abogado: alerta Telegram si hay novedades +``` + +--- + +## 4. Controles transversales + +| Control | Implementación | Archivo | +|---------|---------------|---------| +| Log sanitization | `scrub()` — todo dato externo pasa por acá antes de `logger.*()` | `utils/sanitize.py` | +| Audit trail | `audit.log()` — quién, qué, cuándo, resultado — sin payload sensible | `audit.py` | +| Rate limiting (HTTP) | `limit_req_zone` en Nginx — 30r/s webhook, 10r/min health | `DEPLOY.md § 11` | +| Rate limiting (bot) | Ventana deslizante 20 req/60s por chat_id | `utils/rate_limiter.py` | +| Comparación de secrets | `secrets.compare_digest()` — evita timing oracle en todas las validaciones | `health.py`, `onboarding.py` | +| Env vars post-init | `os.environ.pop()` para FERNET_KEY, tokens y secrets tras cargar Settings | `main.py` | +| Invite codes | `UPDATE WHERE usado=0` atómico — elimina TOCTOU race condition | `database.py` | +| chat_id en logs | Enmascarado como `usr_` + SHA256[:8] — no reversible, sí correlacionable | `health.py` | +| Respuestas a desconocidos | Silencio total; `/health` público devuelve solo `{status, timestamp}` | `telegram_bot.py`, `health.py` | +| Acuse de notificaciones | `PUT /Notificaciones/{id}/recibir` **NUNCA se llama** — efecto procesal irreversible | invariante global | + +--- + +## 5. Limitaciones conocidas + +| Limitación | Motivo | Mitigación | +|------------|--------|------------| +| `docker inspect` expone env vars | Docker no puede evitarlo por diseño | Usar Docker secrets en v1; FERNET_KEY en vault externo (HashiCorp Vault) | +| Credenciales en memoria no se pueden zerear | Python `str` es inmutable; GC no garantizado | `del` inmediato post-uso; sin referencias persistentes | +| lru_cache mantiene wrapping keys en RAM | Tradeoff performance vs isolation | Cache key incluye fingerprint del KEK; se invalida si cambia FERNET_KEY | +| `.env` en disco del servidor | Necesario para arrancar | Permisos `600`, usuario dedicado `pedrito`, fuera del directorio web | +| Backup de DB sin cifrar | `data/pasante.db.bak` queda en disco | Incluir en política de backup cifrado; mismos permisos que la DB |