La clave de la cueva: WiFi de invitados que caduca sola
Introducción
Llega visita, te pide la WiFi, le pasas el QR de la red y listo. El problema aparece la segunda vez que viene: ese QR sigue funcionando. Y la tercera. Y dentro de seis meses, cuando ya ni te acuerdas de que se lo diste. La contraseña de una red WiFi es un secreto compartido de por vida: una vez que sale de tu casa, no vuelve.
Yo quería lo contrario. Quería que cada visita tuviera acceso mientras está aquí y que ese acceso se apague solo. Sin portal cautivo, sin hardware nuevo, sin ponerme a administrar clientes uno por uno. La idea terminó siendo simple de enunciar y bastante entretenida de implementar: rotar la contraseña de la red de invitados cada 24 horas, de forma automática, y tener el QR nuevo siempre a un toque en el celular.
Spoiler: el módem no tiene API. Este post es cómo le hice una.

Por qué rotar la clave
Antes de rotar nada estuve dando vueltas con las alternativas clásicas de "controlar quién entra a mi WiFi":
- Portal cautivo (tipo OpenNDS): el más completo, códigos por sesión, expulsar a alguien al instante. Pero implica meter un AP dedicado y correr el portal en algún lado. Demasiada infraestructura para lo que quería.
- Filtro MAC: los teléfonos modernos usan MAC aleatoria por SSID y la rotan; la lista blanca deja de servir sola. Descartado.
- Rotar la clave de un SSID de invitados aparte: si la red de invitados está separada de la principal, cambiarle la contraseña cada tanto no afecta a nada mío. El QR viejo muere, el nuevo lo genero yo. Cero infraestructura.
La tercera opción gana por descarte y por pereza sana. Solo necesitaba que el módem colaborara.
Dos módems y ninguna API
Mi enlace es de fibra, pero detrás cuelga un router FiberHome que es el que realmente sirve las redes WiFi de la casa. Ese FiberHome trae una función de fábrica llamada Guest WiFi: SSID propio, aislamiento de clientes activado, y hasta un temporizador. Justo lo que quería.

El detalle: su panel de administración es una SPA de Vue servida por un nginx embebido, sin documentación, sin API pública, sin nada. Todo lo que hace el panel son llamadas internas que la aplicación web arma sola. Así que abrí las DevTools y me puse a mirar.
Ingeniería inversa del panel
Toda la aplicación habla con un único endpoint:
POST http://<ip-del-router>/cgi-bin/JSONAPI
{ "cmdType": "..." }

La respuesta trae {"ret": 0} cuando salió bien. A partir de ahí fue cuestión de identificar tres piezas: cómo se autentica, cómo firma cada request, y cómo cifra las contraseñas que manda.
El login
{
"cmdType": "SET_WEB_LOGIN",
"username": "<usuario>",
"password": "<HMAC-SHA256(clave=<constante>, mensaje=contraseña_plana)>"
}
La contraseña no viaja en claro: viaja como un HMAC-SHA256 con una clave constante (quemada en el firmware, visible en el bundle de JavaScript). El request se manda con una cookie uuid que el cliente genera solo, y en la respuesta el servidor te devuelve su uuid de sesión, que es el que importa de ahí en adelante.
El token de firma
Cada llamada que no sea el login lleva un header X-XSRF-TOKEN. Capturé dos requests distintos y comparé sus tokens: mismo tipo de body, tokens distintos. O sea que el token depende del contenido. Probé lo obvio (sha256 del body): no daba. Así que escribí un script corto que probaba combinaciones contra los dos tokens capturados:
cands = {
"sha(body)": lambda b: sha256(b),
"sha(body+const)": lambda b: sha256(b + CONST),
"hmac(const,body)": lambda b: hmac_sha256(CONST, b),
"hmac(uuid,body)": lambda b: hmac_sha256(uuid, b),
# ...
}
Match en las dos capturas:
X-XSRF-TOKEN = HMAC-SHA256(clave = uuid_de_sesión, mensaje = cuerpo_json_exacto)
El uuid que devuelve el login es a la vez la sesión y la clave con la que se firma cada request. Elegante, dentro de todo.
El cifrado de las contraseñas
Los campos de contraseña dentro de los payloads (por ejemplo la clave WPA de la red de invitados) van cifrados con AES de CryptoJS, que es compatible con el formato de OpenSSL:
# cifrar
openssl enc -aes-256-cbc -a -A -md md5 -pass pass:<passphrase-del-firmware>
# descifrar: el mismo comando con -d
La passphrase, otra vez, está quemada en el firmware. Verifiqué el ida y vuelta cifrando una clave conocida, mandándola con SET_WLAN_GUEST, leyéndola de vuelta con GET_WLAN_GUEST y descifrándola: coincidía. La API me pertenecía.
El gotcha del Duration
SET_WLAN_GUEST acepta un campo Duration en minutos. La UI lo presenta como "sin límite de tiempo" cuando vale 0... pero el firmware, con Duration: 0, deja a los invitados asociados sin salida a internet. Necesita una ventana positiva. Le puse 2880 (48 horas): como el cron corre cada 24, siempre re-arma la ventana antes de que se cierre, y si una corrida falla o se atrasa no queda hueco.
Perdí un buen rato con esto pensando que era problema de ruteo. No lo era. Era un 0 que significaba una cosa en la interfaz y otra en el backend.
El script
rotate.sh vive en el servidor y lo dispara un cron a las 5 de la mañana:
0 5 * * * ~/guest-rotate/rotate.sh >/dev/null 2>&1
Lo que hace cada corrida:
- Verifica que llega al FiberHome (si no, intenta reconectar el adaptador y aborta con aviso).
- Se loguea, genera una clave nueva y la aplica.
- Genera la clave con
secrets, no conrandom:"".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12)) - Regenera el QR.
- Escribe un
current.envcon SSID, clave y fecha. - Avisa por ntfy.
Un par de cosas que aprendí a los golpes armando esto:
tr -dc … </dev/urandom | head -c Nrevienta conset -o pipefail.headcierra el pipe,trrecibe SIGPIPE, ypipefailte tumba el script. Por eso la generación de la clave la hace Python.qrencodepor defecto genera un PNG de 1 bit con paleta, y varios visores de Android (el de mi celular entre ellos) lo pintan completamente negro. Terminé pasándolo por Pillow para aplanarlo a RGB sin canal alfa. No era el único problema del visor, pero ayudó.
Así se ve el aviso de cada mañana a las 5:

Para ntfy no usé mi cuenta de admin. Creé un usuario dedicado con permiso write-only a un solo topic y un token para él. Si esa credencial se filtra, lo único que puede hacer es publicar en ese topic. Menor privilegio, siempre.
Un widget con estética de cueva para el QR
El servidor rota la clave, pero yo necesitaba verla desde el celular sin abrir una terminal. La solución fue un shortcut de Termux:Widget: un toque en la pantalla de inicio y aparece el QR.

Adentro tiene más peleas con Android de las que esperaba:
termux-opensobre el PNG → el visor del celular lo pinta negro (de nuevo).termux-opensobre un.htmllocal (file://) → scoped storage lo bloquea, "archivo no encontrado".- Servir desde
http://127.0.0.1en el navegador → esto sí funciona, siempre.
Así que el shortcut trae el QR crudo por SSH (usando una clave restringida con command= que solo puede leer ese archivo y nada más), arma una página HTML, levanta un python3 -m http.server atado a 127.0.0.1, y abre esa URL.
Y ya que estaba, la página no iba a ser un <img> pelado. El blog se llama La Cueva del NeanderTech, así que la página es una cueva: pared de roca con textura de ruido SVG, un resplandor de antorcha arriba, dos antorchas que titilan en los bordes, el nombre de la red "tallado" en piedra, el QR sobre una losa, la contraseña en pigmento rojo, y un friso de animales con huellas de manos en la base. Todo en CSS y SVG inline, sin un solo recurso externo, servido desde localhost.
Es una tontería y me gusta.
Lecciones que me llevo
-
Un panel sin API igual tiene una API. Solo que no está documentada y se llama "las llamadas que hace el frontend". DevTools abiertas, dos capturas, y un rato para entender el esquema de firma.
-
Los gotchas no viven en el protocolo, viven en el entorno. El
Duration: 0, el SIGPIPE depipefail, el PNG negro del visor, elfile://bloqueado, la falta de/tmpen Termux. Ninguno tenía que ver con la ingeniería inversa en sí; todos me costaron más tiempo que ella. -
Menor privilegio hasta en lo chico. La clave SSH del celular tiene un
command=forzado que solo emite el QR. El usuario de ntfy es write-only a un topic. Si cualquiera de las dos se filtra, el daño posible es prácticamente nulo. -
El acceso temporal necesita un mecanismo, no disciplina. Yo podría "acordarme" de cambiar la clave de invitados cada tanto. No lo iba a hacer. La rotación automática convierte una intención que vivía en mi cabeza en un estado que se aplica solo todos los días a las 5 AM.
Conclusión
En un post anterior conté cómo un bypass de auth que dejé "temporalmente" activo terminó con un minero corriendo en mi servidor. El patrón de fondo era el mismo que aquí: una configuración temporal que nunca deja de serlo, porque nada la obliga a caducar.
El QR de WiFi que le pasas a una visita "una sola vez" es exactamente eso. Nace como algo puntual y se queda para siempre, porque el sistema no tiene ninguna noción de que ese acceso debería vencer. La rotación le pone esa noción: cada 24 horas el pasado se borra y solo sigue adentro quien tiene la clave de hoy. Dejó de ser una expectativa mía —"yo controlo quién entra"— para ser un estado que se verifica y se aplica solo, con un aviso en el celular cuando pasa.
No hizo falta un portal cautivo ni un router nuevo. Hizo falta mirar qué hablaba el panel con el módem, y decidir que el acceso a mi casa también tiene fecha de vencimiento.
Referencias
"Un sistema criptográfico debe ser seguro aunque todo sobre él, excepto la clave, sea de conocimiento público."

