CreaRack-SL

Servicio ssh_host_keys: TOFU server-side para conexiones SSH

Descripción

Servicio que implementa Trust-On-First-Use (TOFU) por organización para las conexiones SSH server-side que ScrapliManager establece hacia dispositivos de red. Cierra el hallazgo de seguridad Auditoría Suprema Etapa 3, network A9/sa1 + M3/sa2: antes, ScrapliManager conectaba con auth_strict_key=False, deshabilitando completamente la verificación de identidad del host → un atacante en el camino podía suplantar el dispositivo y capturar credenciales.

Ubicación y cobertura

  • Archivo: network/services/ssh_host_keys.py (~99 LOC)
  • Tests: tests/api/test_network_ssh_tofu.py (6 tests)
  • Sin modelo Django, sin migración: el almacén es un known_hosts por organización bajo MEDIA_ROOT/ssh_known_hosts/<org_id>/

Diseño

TOFU por organización

Dos tenants distintos pueden tener legitimately la misma IP RFC1918 para dispositivos diferentes. Por eso:

  • Almacén separado por org_id: MEDIA_ROOT/ssh_known_hosts/<org_id>/known_hosts
  • Si org_id=None (sin tenant), comportamiento antiguo (no-check)
  • La clave se captura una sola vez en el primer intento; después se exige en todas las conexiones

Nunca reduce conectividad

Refleja el mismo principio que el Agent (terminal/agent/network/host_keys.py, s124):

  • Si no hay registro para el host+puerto y no se puede captar la clave (timeout, fallo de red, servidor no responde), degrada a auth_strict_key=False para ese único intento
  • Así no bloquea nuevas conexiones a equipos que llegan por primera vez
  • Pero si ya hay una clave registrada, la verifica con ssh_known_hosts_file — una clave distinta hace fallar la conexión (MITM detectado)

Funciones públicas

scrapli_strict_args(org_id, host: str, port: int = 22) -> dict

Firma: Async, retorna dict de kwargs para ScrapliManager.connect.

Lógica:

  1. Si org_id es falsy → retorna {"auth_strict_key": False} (sin tenant disponible, comportamiento antiguo)
  2. Si el host+puerto está registrado en el known_hosts de org_id:
    • Retorna {"auth_strict_key": True, "ssh_known_hosts_file": "<path>"} (stricto)
  3. Si no está registrado:
    • Intenta captar la clave con _fetch_key(host, port) (timeout 10s)
    • Si se captura: la registra con _record(...) y retorna dict stricto
    • Si falla: retorna dict no-stricto (degrada, no bloquea)

Datos de entrada (origen):

  • org_id: del request (en scripts.py) o del device (en backups.py)
  • host, port: pasados desde ScrapliManager.connect / execute_script

Sub-componentes privados

_store_path(org_id) -> Path

Retorna MEDIA_ROOT/ssh_known_hosts/<org_id>/known_hosts.

_is_recorded(path, host, port) -> bool

Lee el archivo y busca si ya existe una línea para el host (con el token SSH estándar: host o [host]:port si puerto != 22).

_fetch_key(host, port) -> key_object

Usa asyncssh.get_server_host_key() para captar la clave pública del servidor (timeout 10s). Retorna None si falla.

_record(path, host, port, key) -> None

Exporta la clave en formato OpenSSH y la añade al archivo (con escritura thread-safe mediante lock). Crea el directorio si no existe.

_token(host, port) -> str

Genera el primer campo del known_hosts: host (puerto 22) o [host]:port (otro puerto).

Integración

Modificaciones en ScrapliManager (network/services/scrapli_manager.py)

  • connect(..., org_id=None): nuevo parámetro, pasado a scrapli_strict_args()
  • Reemplaza hardcoded auth_strict_key=False con **strict_args que incluye host-key file si aplica

Propagación de org_id

  • scripts.py (endpoint run_script): obtiene org_id del request y lo pasa a execute_script(..., org_id=org.id)
  • backups.py (tarea fetch_config): obtiene org_id del device y lo pasa a connect(..., org_id=device.organization_id)
  • ssh_reader.py (lectura de puertos): anotado con org_id=None (default seguro), a propagar en próximas sesiones

Casos de test

TestEscenario
test_no_org_means_no_checkingSin org_id → no-check (comportamiento antiguo)
test_tofu_records_first_time_and_enforcesPrimera conexión: captura clave, registra, retorna stricto
test_recorded_host_is_not_refetchedHost ya registrado: no refetch, retorna stricto sin llamar a _fetch_key
test_fetch_failure_degrades_to_no_checkNo hay registro y no se puede captar → degrada a no-check
test_per_org_isolationRFC1918 IP en 2 orgs → almacenes separados
test_non_standard_port_tokenPuerto ≠ 22 → token con [host]:port

Referencias en el código

  • network/services/scrapli_manager.py: líneas ~88–91 (import y llamada a scrapli_strict_args)
  • network/api/scripts.py: línea ~318 (propagación de org.id)
  • network/api/backups.py: línea ~199 (propagación de device.organization_id)

Véase también

  • [[entity—network—service—scrapli-manager]]
  • [[feature—security—ssh-tofu-server-side]]
  • [[decision—20260611—ssh-tofu-auditoria-suprema-a9-m3]]
  • [[concept—network—ssh-host-verification]]
  • [[concept—saas—multi-tenancy]]