CreaRack-SL

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:

  1. Interponer un proxy SSH falso.
  2. Interceptar credenciales o comandos.
  3. 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:

  • True si la clave fue registrada o ya existe.
  • False si 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:

  • True si coincide con lo registrado.
  • False si no hay registro o no coincide.

Comportamiento:

  • Si no hay registro: retorna False (requiere register_host_key primero, 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 con asyncssh).
  • 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

  1. Agent inicia: carga known_hosts en memoria (diccionario hostname → key).
  2. SaaS crea target (ej. 192.168.1.100): Agent no sabe la clave aún.
  3. 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.
  4. 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.
  5. 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”.

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_hosts se carga una sola vez al iniciar el Agent (en startup de FastAPI).
  • Lecturas: acceso directo al diccionario (thread-safe para reads en Python).
  • Escrituras: usa lock (threading.Lock en network/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() retorna False.
  • 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.py incluye tests para register_host_key / verify_host_key.
  • Fixtures: crea fichero known_hosts temporal 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_hosts soporta 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_hosts manualmente (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]]