Dos logins en el mismo segundo, dos claves idénticas: dentro del bug de aislamiento de sesiones del webapp de Hermes

Un compañero necesita que le dejes el portátil. Abres el workspace de Hermes en el navegador, pulsas cerrar sesión, ves aparecer el formulario de login y deslizas la máquina por la mesa. Por lo que a ti respecta, el asunto está resuelto.
No lo estaba. Un arreglo fusionado el 26 de octubre (PR #145108) explica el agujero: si otro camino de inicio de sesión se completa justo en el mismo segundo que tu login en el navegador, a ambas sesiones se les entregan tokens idénticos byte a byte. El cierre de sesión eliminó la cookie del navegador; la misma clave siguió funcionando en otro sitio, incluso después de reiniciar.
Cómo funciona de verdad este inicio de sesión
El webapp de Hermes está detrás de una puerta de inicio de sesión: cualquier navegador tiene que autenticarse primero. Usa el sistema de cuentas del dashboard, y el proveedor de contraseña integrado acuña dos tokens por login:
- un access token, de vida corta, que prueba quién hace esta petición;
- un refresh token, de vida más larga, que se canjea por nuevos access tokens.
Ambos son cadenas firmadas que llevan tres datos: sub (quién eres), kind (access o refresh) y exp (cuándo caduca). El servidor verifica la firma y no necesita consultar la base de datos en cada petición.
El diseño está bien. El problema estaba en qué se firma. Esos tres campos eran todo el payload, y exp tiene granularidad de segundo. Dos sesiones acuñadas para el mismo usuario dentro de un segundo producían por tanto exactamente la misma entrada de firma, y por tanto exactamente los mismos bytes de token.
Por qué importaba una colisión
Unos tokens idénticos serían una curiosidad si las sesiones no tuvieran relación. Pero el webapp tiene un contrato explícito: las apps nativas que iniciaron sesión en el listener del webapp siguen con la sesión abierta tras hermes webapp --sign-out; solo se cierra la sesión de los navegadores. Eso es deliberado: cerrar sesión en un navegador no debería desconectar tu app de escritorio.
Para cumplirlo, el listener guarda digests del par de tokens que entregó a una app nativa en webapp/native-sign-in-tokens.json, y mantiene a propósito ese registro durante el cierre de sesión, que es la razón por la que la app de escritorio sobrevive a un reinicio sin un login nuevo.
Ahí se cierra el bucle. Si un login de navegador y un inicio de sesión nativo caen en el mismo segundo, sus tokens son idénticos, luego los tokens del navegador también coinciden con ese registro de «app nativa», luego tú cierras sesión, la cookie del navegador se borra, pero la misma cadena sigue valiendo como bearer token, y su refresh token sigue renovando en /auth/native/refresh. El test dice el resultado sin adornos: funciona antes del cierre de sesión, después y tras un reinicio.
Dicho de forma simple: ese cierre de sesión surtió efecto en primer plano y no en segundo plano.
Cinco líneas, pero apuntando a la raíz
El cambio está en _mint_session, dentro de plugins/dashboard_auth/basic/__init__.py: cada sesión acuñada recibe un jti aleatorio (JWT ID, el campo estándar para numerar un token individual), firmado tanto en el access token como en el refresh token.
jti = secrets.token_urlsafe(16)
_sign({"sub": user, "kind": "access", "exp": exp, "jti": jti}, secret)
_sign({"sub": user, "kind": "refresh", "exp": now + TTL, "jti": jti}, secret)
secrets.token_urlsafe(16) da 128 bits aleatorios, suficiente para que «mismo segundo, mismo usuario, dos logins» siga produciendo dos pares distintos. La verificación no cambia: misma comprobación de firma, misma comprobación de caducidad. Solo que el payload firmado ha ganado un campo que nadie puede predecir.
Los tests se leen como el informe del bug. Congela el reloj, haz dos logins y una renovación, y asegura tres pares distintos, todos los cuales verifican. Un segundo test ejecuta un inicio de sesión nativo RFC 8252 completo (el navegador del sistema abre la página de autorización, login con contraseña, código canjeado por tokens), luego hace un login de navegador dentro del mismo segundo congelado y asegura que los tokens del navegador se rechazan por sí solos como bearer antes del cierre de sesión, después de él y tras un reinicio, mientras la app nativa sigue con la sesión abierta exactamente igual que antes.
Tres lecciones que merece la pena conservar
Una: cualquier esquema de firma en el que «mismo contenido ⇒ mismo token» necesita un campo impredecible. Firmar solo campos de negocio (sub, kind, exp) convierte la marca de tiempo en tu única fuente de unicidad, y con granularidad de segundo la concurrencia colisiona. Para eso está jti. Si acuñas tus propios tokens, añade una huella aleatoria o usa un mecanismo que garantice unicidad.
Dos: las comprobaciones de seguridad tienen que cubrir los varios puntos de entrada a una misma identidad. La verificación de firma no tenía nada malo. Lo que falló fue que dos entradas —las cookies del navegador y los bearer tokens nativos— compartían una única forma de reconocer credenciales: comparar digests. Comparar digests está bien; el fallo era que las dos claves comparadas podían ser la misma clave. Decidir «¿es esta la sesión que cerró sesión?» necesita identidad de sesión, no contenido de credencial.
Tres: el cierre de sesión necesita una frontera semántica explícita. Hoy el contrato es claro: hermes webapp --sign-out cierra solo la sesión de los navegadores. Alguien pidió que también se cerrara la de las apps nativas (issue #144668), y la respuesta de los mantenedores es que bajo el contrato actual esto funciona como se pretende: una decisión de producto, no una vulnerabilidad. Pero deberías saber qué hace realmente el botón que has pulsado.
Qué hacer al respecto
El arreglo se fusionó en main el 26 de octubre; la v0.22.5 (publicada el 23 de octubre) no lo contiene, así que llegará en la próxima versión. Mientras tanto, si la máquina es compartida o le pasas el navegador a alguien:
- no te fíes solo del cierre de sesión. El aislamiento real es apagar el listener del webapp (
webapp.enabled: falsey reiniciar el gateway) o volver a atarlo a127.0.0.1; - en una máquina compartida, da profiles separados en lugar de compartir el login de un profile;
- reinicia el gateway antes de entregar la máquina, para que el estado de sesión en memoria se vaya con él.
Para la mecánica que hay alrededor, cómo se traspasa el enlace de inicio de sesión de un solo uso y qué es el webapp y cómo activarlo son los dos siguientes pasos. Si tu dashboard también está expuesto, el razonamiento de el artículo sobre el endurecimiento del login del dashboard se aplica a la misma frontera.
La lección en una línea: cerrar sesión tiene que terminar «esta sesión», no «esta cadena». Cuando esas dos cosas dejan de ser uno a uno, cerrar sesión solo cierra la puerta mientras la llave sigue en el bolsillo de alguien.