Dois logins no mesmo segundo, duas chaves idênticas: por dentro do bug de isolamento de sessão do webapp do Hermes


Um colega precisa pegar seu notebook emprestado. Você abre o workspace do Hermes no navegador, clica em sair, vê o formulário de login aparecer e empurra a máquina pela mesa afora. Para você, o assunto está resolvido.

Não estava. Uma correção mergeada em 26 de outubro (PR #145108) explica o buraco: se outro caminho de login terminar bem no mesmo segundo que o seu login pelo navegador, as duas sessões recebem tokens idênticos byte a byte. O logout apagou o cookie do navegador; a mesma chave continuou funcionando em outro lugar — inclusive depois de reiniciar.

Como esse login funciona de verdade

O webapp do Hermes fica atrás de um portão de autenticação: qualquer navegador precisa se autenticar primeiro. Ele usa o sistema de contas do dashboard, e o provider de senha embutido emite dois tokens por login —

  • um access token, de vida curta, que prova quem está fazendo a requisição;
  • um refresh token, de vida mais longa, trocado por novos access tokens.

Os dois são strings assinadas que carregam três informações: sub (quem você é), kind (access ou refresh) e exp (quando expira). O servidor confere a assinatura e não precisa consultar o banco a cada requisição.

O desenho está ok. O problema estava no que entra na assinatura. Esses três campos eram o payload inteiro, e exp tem granularidade de segundo. Duas sessões emitidas para o mesmo usuário dentro de um segundo produziam, portanto, exatamente a mesma entrada de assinatura — e, por consequência, exatamente os mesmos bytes de token.

Por que uma colisão importava

Tokens idênticos seriam só uma curiosidade se as sessões não tivessem nada a ver uma com a outra. Só que o webapp tem um contrato explícito: apps nativos que fizeram login pelo listener do webapp continuam logados depois de hermes webapp --sign-out; só os navegadores são desconectados. Isso é proposital — sair da conta no navegador não deveria derrubar seu app de desktop.

Para cumprir isso, o listener grava digests do par de tokens que entregou a um app nativo em webapp/native-sign-in-tokens.json, e mantém esse registro de propósito durante o logout — é por isso que o app de desktop sobrevive a um restart sem precisar de um login novo.

Aí o ciclo se fecha. Se um login de navegador e um login nativo caem no mesmo segundo, os tokens são idênticos → os tokens do navegador também batem com aquele registro de “app nativo” → você sai da conta, o cookie do navegador é descartado, mas a mesma string ainda vale como bearer token, e o refresh token dela continua renovando em /auth/native/refresh. O teste diz o resultado sem rodeios: funciona antes do logout, depois dele e depois de um restart.

Resumindo: aquele logout valeu no primeiro plano, não no segundo.

Cinco linhas, mas mirando a raiz

A mudança está em _mint_session, em plugins/dashboard_auth/basic/__init__.py: toda sessão emitida ganha um jti aleatório (JWT ID, o campo padrão para numerar um token individual), assinado tanto no access quanto no 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) entrega 128 bits aleatórios — o suficiente para que “mesmo segundo, mesmo usuário, dois logins” ainda resulte em dois pares distintos. A verificação não mudou: mesma checagem de assinatura, mesma checagem de expiração. Só o payload assinado ganhou um campo que ninguém consegue prever.

Os testes parecem o relato do bug. Congele o relógio, faça dois logins e uma renovação e verifique três pares distintos, todos válidos na assinatura. Um segundo teste executa um login nativo completo conforme a RFC 8252 (o navegador do sistema abre a página de autorização, login por senha, código trocado por tokens), depois faz um login de navegador dentro do mesmo segundo congelado e verifica que os tokens do navegador são recusados sozinhos como bearer antes do logout, depois dele e depois de um restart — enquanto o app nativo continua logado exatamente como antes.

Três lições que vale guardar

Um: todo esquema de assinatura em que “mesmo conteúdo ⇒ mesmo token” precisa de um campo imprevisível. Assinar só campos de negócio (sub, kind, exp) faz do timestamp sua única fonte de unicidade, e com granularidade de segundo a concorrência colide. É para isso que existe o jti. Se você emite seus próprios tokens, inclua uma impressão digital aleatória ou use um mecanismo que garanta unicidade.

Dois: checagem de segurança tem que cobrir todas as portas de entrada de uma mesma identidade. A verificação de assinatura não tinha nada de errado. O problema foi que duas entradas — cookies de navegador e bearer tokens nativos — compartilhavam uma única forma de reconhecer credenciais: comparar digests. Comparar digests é ok; a falha era que as duas chaves comparadas podiam ser a mesma chave. Decidir “esta é a sessão que fez logout” exige identidade de sessão, não conteúdo de credencial.

Três: logout precisa de uma fronteira semântica explícita. Hoje o contrato é claro: hermes webapp --sign-out desconecta só os navegadores. Alguém já pediu que apps nativos também fossem desconectados (issue #144668), e a resposta dos mantenedores é que, sob o contrato atual, isso funciona como projetado — decisão de produto, não vulnerabilidade. Mesmo assim, vale saber o que o botão que você apertou realmente faz.

O que fazer a respeito

A correção foi mergeada na main em 26 de outubro; a v0.22.5 (lançada em 23 de outubro) não a contém, então ela chega na próxima versão. Até lá, se a máquina é compartilhada ou você passa o navegador para alguém:

  • não confie só no logout. O isolamento de verdade é desligar o listener do webapp (webapp.enabled: false, e depois reiniciar o gateway) ou voltar a vinculá-lo a 127.0.0.1;
  • em máquina compartilhada, dê profiles separados em vez de compartilhar o login de um profile;
  • reinicie o gateway antes de entregar a máquina, para que o estado de sessão em memória vá junto.

Para a mecânica em volta disso, como o link de login de uso único é entregue e o que é o webapp e como habilitá-lo são as duas leituras seguintes. Se o seu dashboard também está exposto, o raciocínio do post sobre hardening do login do dashboard vale para a mesma fronteira.

A lição em uma linha: logout tem que encerrar “esta sessão”, não “esta string”. Quando os dois deixam de ser um-para-um, sair da conta só fecha a porta enquanto a chave continua no bolso de alguém.