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>
8.0 KiB
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 |