Servicio crypto del Agente — Cifrado DPAPI en reposo (terminal/agent/core/crypto.py)
Ubicación
Ruta: terminal/agent/core/crypto.py
Módulo Python: terminal.agent.core.crypto
Líneas: ~103
Estado: ✅ Activo desde s124 (2026-06-10)
Propósito
Encripta secretos sensibles (credenciales SNMP: snmp_community, snmp_v3_auth_key, snmp_v3_priv_key) antes de escribirlos en la BD local (metrics.db) del Agent. Usa Windows DPAPI (Data Protection API), atado a la cuenta Windows del usuario, garantizando que solo esa cuenta puede descifrar los datos.
Interfaz pública
Función encrypt_secret(value: str | None) -> str | None
Firma:
def encrypt_secret(value: str | None) -> str | None
Propósito: Encripta un secreto para almacenamiento en reposo.
Comportamiento:
- Valores None/vacíos: pasan sin cambios.
- Valores ya encriptados (prefijo
"dpapi:"): retorna sin cambios (idempotente). - No-Windows (dev/CI): retorna el valor tal cual (la producción solo corre en Windows
.exe). - En error: logea excepción y retorna el valor en claro (fallback seguro — nunca falla).
Retorno: string con prefijo "dpapi:" + token base64, o el valor original si no se cifró.
Función decrypt_secret(value: str | None) -> str | None
Firma:
def decrypt_secret(value: str | None) -> str | None
Propósito: Descifra un secreto almacenado.
Comportamiento:
- Valores None/vacíos: pasan sin cambios.
- Valores en claro (sin prefijo
"dpapi:"): pasan sin cambios (compatibilidad con DBs legacy previas al cifrado). - Valores encriptados: descifra usando Windows DPAPI.
- En error: logea excepción y retorna string vacío.
Retorno: el secreto en claro, o string vacío si descifrado falló.
Funciones internas (Windows DPAPI)
_dpapi_encrypt(data: bytes) -> bytes
Llama a CryptProtectData (ctypes). Eleva RuntimeError si falla. Libera memoria alocada por Windows.
_dpapi_decrypt(data: bytes) -> bytes
Llama a CryptUnprotectData (ctypes). Eleva RuntimeError si falla. Libera memoria.
Integración en el sistema
core/store.py — TimeSeriesStore
En lectura (get_target_list()):
for row in rows:
for field in _SECRET_TARGET_FIELDS: # ("snmp_community", "snmp_v3_auth_key", "snmp_v3_priv_key")
if field in row:
row[field] = decrypt_secret(row[field])
return rows
En escritura (upsert_target()):
encrypt_secret(target.get("snmp_community")),
encrypt_secret(target.get("snmp_v3_auth_key", "")),
encrypt_secret(target.get("snmp_v3_priv_key", "")),
Flujo completo
- SaaS → Agent (vía WebSocket): SaaS envía target con credenciales en claro.
- Agent escribe (
upsert_target):crypto.encrypt_secret()→ almacena con prefijo"dpapi:"enmetrics.db. - Agent lee (
get_target_list):crypto.decrypt_secret()→ descifra en memoria, retorna en claro a quien lo llamó (ej.routes/network.py). - Uso local: credencial está en claro solo en memoria del proceso Agent, nunca toca disco así.
Compatibilidad hacia atrás (legacy)
Problema: DBs existentes pueden tener credenciales en claro (s123 y antes).
Solución: decrypt_secret() detecta el prefijo "dpapi:":
- Con prefijo → descifra (valor nuevo).
- Sin prefijo → retorna como-está (valor legacy en claro).
Auto-recifrado: cuando SaaS hace upsert_target() (push de actualización), encrypt_secret() recifra el valor, migrando la BD poco a poco sin downtime.
Detalles de implementación
Formato de almacenamiento
"dpapi:" + base64(CryptProtectData(utf-8_bytes))
- Prefijo:
"dpapi:"(ASCII, 6 caracteres). - Token: base64 del binario cifrado por DPAPI.
- Ventaja: distingue valores cifrados de legacy sin necesidad de metadatos extra.
Variables privadas
_ENC_PREFIX = "dpapi:"— prefijo de detección._WINDOWS = sys.platform == "win32"— flag para evitar importar ctypes en non-Windows._SECRET_TARGET_FIELDSenstore.py— tupla de nombres de columna a cifrar/descifrar.
Logging
Usa logger = logging.getLogger("agent.crypto") → mensajes en nivel INFO/ERROR, sin exponer valores secretos.
Seguridad
DPAPI (Data Protection API)
- Estándar Windows: encriptación a nivel SO, tied a user account + machine key.
- No requiere master key: el usuario no necesita recordar/guardar una contraseña.
- Limitación: solo funciona en Windows. En dev/non-Windows, los valores se almacenan en claro (es una trade-off aceptable: la producción es solo Windows
.exe).
Protección del known_hosts
El fichero known_hosts (creado por network/host_keys.py) contiene public keys de los dispositivos, no secretos. No requiere cifrado DPAPI (los public keys no son confidenciales).
Testing
tests/agent/test_agent_hardening_b1.pyincluye tests paraencrypt_secret/decrypt_secret.- Prueba de round-trip DPAPI se salta fuera de Windows (la fixture verifica
sys.platform). - Pruebas de compatibilidad legacy (valor en claro + descifrado).
Notas de operación
- Sin migrations: crypto.py es un módulo puro de utilidad, sin modelos Django.
- No bloquea: encriptación DPAPI es O(1) en la mayoría de casos (claves cortas <1KB).
- Error handling: si DPAPI falla (ej. user account corrupted), la función loga y retorna un string seguro (vacío en descifrado, claro en encriptado) — nunca levanta excepción no-manejada.
Véase también
- [[feature—terminal—agent-hardening-b1]]
- [[entity—terminal—service—agent-host-keys]]
- [[entity—terminal—service—store]]
- [[concept—security—dpapi-windows]]
- [[concept—security—at-rest-encryption]]