Servicio host-keys del Agente — SSH TOFU anti-MITM (terminal/agent/network/host_keys.py)
Ubicación
Ruta: terminal/agent/network/host_keys.py
Módulo Python: terminal.agent.network.host_keys
Estado: ✅ Activo desde s124 (2026-06-10)
Propósito
Implementa Trust-On-First-Use (TOFU) para SSH, detectando y previniendo ataques MITM (Man-in-the-Middle) en conexiones a dispositivos de red. Registra la clave pública SSH de cada dispositivo en la primera conexión y rechaza cambios en identidad en conexiones posteriores.
Contexto del problema (R4)
Antes (s123): El Agent conectaba con known_hosts=None y auth_strict_key=False — la verificación de identidad del servidor SSH estaba desactivada. Un atacante en la LAN del cliente podía:
- Interponer un proxy SSH falso.
- Interceptar credenciales o comandos.
- Inyectar configuración maliciosa.
Solución: TOFU — la primera conexión es de confianza (ya que el cliente debe confiar en el dispositivo para crear el target); conexiones posteriores verifican identidad.
Interfaz pública
Función register_host_key(hostname: str, key: str | bytes) -> bool
Firma:
def register_host_key(hostname: str, key: str | bytes) -> bool
Propósito: Registra la clave pública de un dispositivo en el fichero known_hosts.
Entrada:
hostname: IP o FQDN del dispositivo (ej.192.168.1.100).key: clave pública en formato OpenSSH estándar, o binario crudo.
Salida:
Truesi la clave fue registrada o ya existe.Falsesi falló (ej. fichero locked, permisos insuficientes).
Comportamiento:
- Si el host no existe en
known_hosts: lo añade. - Si ya existe con la misma clave: retorna
True(idempotente). - Si existe con clave diferente: levanta excepción (host key conflict) — el llamador debe decidir (ej. revisar manualmente).
Función verify_host_key(hostname: str, key: str | bytes) -> bool
Firma:
def verify_host_key(hostname: str, key: str | bytes) -> bool
Propósito: Verifica que la clave de un dispositivo coincide con la registrada.
Entrada:
hostname: IP o FQDN.key: clave pública recibida en la conexión SSH.
Salida:
Truesi coincide con lo registrado.Falsesi no hay registro o no coincide.
Comportamiento:
- Si no hay registro: retorna
False(requiereregister_host_keyprimero, o fallback TOFU). - Si hay registro y coincide: retorna
True. - Si hay registro y no coincide: retorna
False(posible MITM detectado).
Función get_known_hosts_path() -> Path
Firma:
def get_known_hosts_path() -> Path
Propósito: Retorna la ruta del fichero known_hosts.
Ubicación:
- Windows:
%APPDATA%\CreaRackAgent\known_hosts(compatible conasyncssh). - Fallback:
./.known_hosts(dev/testing).
Formato de known_hosts: OpenSSH estándar (compatible con asyncssh y Scrapli):
192.168.1.100 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMz...
192.168.1.101 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5NzS...
Integración en el sistema
network/ssh.py — SSHBridge
En SSHBridge.connect():
# Primera conexión: TOFU
if not host_keys.verify_host_key(hostname, received_key):
if not host_keys.register_host_key(hostname, received_key):
raise HostKeyError(f"Cannot register key for {hostname}")
# Conexiones posteriores: verificación estricta
if not host_keys.verify_host_key(hostname, received_key):
raise HostKeyError(f"Host key mismatch for {hostname}")
routes/network.py — Endpoint /network/ssh-show
Llama a SSHBridge.connect() que internamente usa host_keys.py. Si hay mismatch:
- Respuesta 401 Unauthorized con mensaje:
"Host key conflict: possible MITM"
Flujo completo
- Agent inicia: carga
known_hostsen memoria (diccionario hostname → key). - SaaS crea target (ej.
192.168.1.100): Agent no sabe la clave aún. - Usuario hace SSH (click en
/network/ssh-show):- Agent intenta conectar a
192.168.1.100. - Recibe la clave pública del servidor SSH.
- Verifica en
known_hosts: no está. - TOFU: registra la clave (asume que la LAN es segura en la 1ª conexión).
- Conexión exitosa.
- Agent intenta conectar a
- Día siguiente: usuario vuelve a hacer SSH:
- Agent recibe clave de
192.168.1.100. - Verifica en
known_hosts: sí existe, coincide. - Conexión exitosa.
- Agent recibe clave de
- Ataque MITM simulado: atacante interpone proxy:
- Agent recibe clave diferente de
192.168.1.100. - Verifica en
known_hosts: existe, NO coincide. - Rechaza conexión → endpoint retorna 401 “Host key conflict”.
- Agent recibe clave diferente de
Detalles de implementación
Almacenamiento
Fichero: known_hosts (formato OpenSSH estándar).
# Estructura por línea:
hostname key_type key_base64
Ejemplo:
192.168.1.100 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMz...
Compatibilidad:
- asyncssh lee/escribe directamente en este formato.
- Scrapli (Netmiko) también lo soporta.
- Compatible con
ssh-keyscan(herramienta estándar de OpenSSH).
Parsing
def _parse_known_hosts_line(line: str) -> tuple[str, str] | None:
"""Extrae hostname y key_base64 de una línea."""
parts = line.strip().split()
if len(parts) >= 3:
return (parts[0], " ".join(parts[1:]))
return None
Concurrencia
known_hostsse carga una sola vez al iniciar el Agent (enstartupde FastAPI).- Lecturas: acceso directo al diccionario (thread-safe para reads en Python).
- Escrituras: usa lock (
threading.Lockennetwork/ssh.py) para evitar race conditions.
Logging
- INFO:
"Registered host key for 192.168.1.100" - WARNING:
"Host key mismatch for 192.168.1.100: possible MITM" - ERROR:
"Failed to write known_hosts: {reason}"
Nunca logea el contenido de las claves (seguridad por defecto).
Degradación segura (fallback)
Escenario: primera conexión a un dispositivo, pero asyncssh.get_server_host_key() falla (ej. dispositivo no responde a key probe).
Comportamiento:
register_host_key()retornaFalse.- Llamador (SSHBridge) puede decidir:
- Fallback TOFU: permitir conexión sin verificación (aceptar el riesgo).
- Rechazar: fallar la conexión (seguridad estricta, pero puede romper conectividad).
Criterio actual: fallback TOFU (nunca reduce conectividad, pero logea una advertencia).
Testing
tests/agent/test_agent_hardening_b1.pyincluye tests pararegister_host_key/verify_host_key.- Fixtures: crea fichero
known_hoststemporal en disco. - Pruebas de: registro, verificación exitosa, mismatch, degradación.
Notas de operación
- Tamaño de
known_hosts: típicamente <5KB incluso con 1000 dispositivos (una línea por dispositivo, ~30-50 bytes). - Permisos del fichero: 600 (rw-------) para prevenir lectura no autorizada.
- Auditoría: log de “Host key changed” puede servir para investigar cambios reales vs ataques (ej. reemplazo de switch, actualización de firmware SSH).
Limitaciones conocidas
- ECDSA vs RSA: el formato
known_hostssoporta cualquier tipo de clave SSH. El Agent usa el tipo que devuelve el servidor. - Cambios legítimos: si un admin actualiza el certificado SSH del dispositivo, la conexión falla hasta que se actualice
known_hostsmanualmente (puede ser tedioso; considerar un endpoint de “reset” para dispositivos administrativos).
Véase también
- [[feature—terminal—agent-hardening-b1]]
- [[entity—terminal—service—agent-crypto]]
- [[concept—network—ssh-tofu]]
- [[concept—network—ssh-mitm-protection]]
- [[entity—terminal—network—sshbridge]]