Files
hechakuaa_bot/SECURITY.md
markosbenitez f5f7fdc179 docs: arquitectura con seguridad en CLAUDE.md + SECURITY.md dedicado
CLAUDE.md: diagrama Mermaid reemplaza file-tree desactualizado; incluye
todos los componentes actuales con controles de seguridad anotados.
Stack actualizado con aiohttp y Docker. Referencia a SECURITY.md.

SECURITY.md (nuevo): diagrama defensa en profundidad (7 vectores → controles),
diagrama vault.py v3 con capas KEK+DEK, diagrama de ciclo de vida de
credenciales (secuencia), tabla de controles transversales y limitaciones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:14:33 -03:00

8.0 KiB

Arquitectura de seguridad — Pedrito Hechakuaa

Versión actual: v0.4.1. Ver 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.

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.

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

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