Pular para conteúdo

ADR 0010 — O token de API é guardado como hash, e não cifrado como as outras credenciais

Status

Proposta — 2026-09-18.

Exigida por: AGENTS.md §16 — divergir de um padrão estabelecido da casa exige ADR. O padrão é TheBand.Encrypted.Binary, e toda credencial desta plataforma o usa.

Depende de: ADR 0009 — é ela que decide abrir a API por token.

Realiza: spec 061, FR-003 a FR-005.

Fundamenta-se em: avaliação de segurança de 2026-09-09, decisão Q1.

Contexto

A casa tem um padrão para guardar segredo em repouso, e ele é bom: TheBand.Encrypted.Binary, que é Cloak com AES-GCM de 256 bits, chave mestra vinda do ambiente, cifragem no Ecto.Type e nunca no código de aplicação. É o que protege o token do GitHub, a chave do provedor de modelo, e toda credencial de terceiro.

Aplicá-lo ao token de API seria o caminho sem atrito — e seria errado.

As duas coisas que parecem a mesma e não são

credencial de terceiro token de API
quem gera o GitHub, o provedor de modelo esta plataforma
o que a plataforma faz com ela replica — manda de volta ao terceiro a cada chamada confere — compara com o que chega
precisa do valor em claro depois? sim, sempre não, nunca
proteção certa cifragem reversível função de digestão

A pergunta que separa as duas é uma só: a plataforma precisa recuperar o valor? Para o token do GitHub, sim — sem ele não há chamada. Para o token de API, não: o cliente apresenta o valor, e a plataforma só precisa saber se bate.

Guardar cifrado o que só precisa ser conferido é guardar uma porta a mais. Com a chave mestra, TheBand.Encrypted.Binary devolve todos os tokens em claro.

Decisão

SHA-256 do segredo, comparado com Plug.Crypto.secure_compare/2, e a busca feita pelo id público.

O token tem três partes — tb_api_<id_publico>_<segredo> —, e o formato é consequência da decisão, não preferência.

Alternativas, e por que cada uma foi recusada

TheBand.Encrypted.Binary — o padrão da casa

Recusada por ser reversível. É a proteção certa para o que a plataforma precisa replicar, e a errada para o que ela só precisa conferir. Manter o padrão aqui aumentaria a superfície sem aumentar a garantia: um vazamento da chave mestra passaria a incluir as credenciais de entrada na própria plataforma, e não só as de saída.

O padrão continua certo onde ele está. Esta ADR não o enfraquece; delimita.

Bcrypt.hash_pwd_salt/1 — o hash "mais forte"

Recusada por custo e por busca. São ~100 ms por verificação, medidos e declarados no próprio mix.exs. Esse custo é a proteção contra senha humana de baixa entropia, onde o atacante percorre dicionário; numa API que atende requisição atrás de requisição, é auto-negação de serviço.

E há um segundo motivo, menos óbvio: bcrypt tem sal por linha, então não dá para buscar por índice. Conferir exigiria carregar candidatos e testar um a um.

O que decide é a entropia da entrada, não a preferência pelo hash mais forte. São 32 bytes de gerador criptográfico — 256 bits. Não há dicionário a percorrer, e o alongamento de chave não protege contra nada que exista aqui.

As duas consequências que a decisão arrasta, e a segunda é fácil de errar

A comparação tem de ser em tempo constante

Hoje a sessão compara token com ==, e está correta: o valor vem de cookie assinado pelo próprio servidor, e sem a assinatura não se itera valor para medir tempo.

Na API o valor vem cru de um cabeçalho controlado por quem chama. O canal de tempo é alcançável, e Plug.Crypto.secure_compare/2 é obrigatório.

A busca tem de ser pelo id público, e nunca pelo hash

where: t.token_hash == ^hash entrega a comparação ao Postgres, fora do nosso controle de tempo — e desfaz a garantia anterior no mesmo gesto que parecia cumpri-la.

É por isso que o token tem duas partes além do prefixo. O id público é indexado e único, e é por ele que a linha é achada; o segredo é conferido em memória, em tempo constante.

Consequências

O que melhora. Um vazamento do banco não devolve token nenhum. A verificação custa um SHA-256 — microssegundos —, e a API aguenta requisição em série.

O que piora. O token fica mais comprido, e o parser precisa de formato fixo com teste para cada entrada malformada. O id público é um dado a mais que vaza em qualquer log de cabeçalho — então log de cabeçalho Authorization não pode existir, e isso passa a ser invariante, não recomendação.

O que o esquema perde de graça. Não há como recuperar um token perdido: o caminho é revogar e gerar outro. É o comportamento certo, e a tela precisa dizê-lo no momento em que mostra o valor.

O que a casa ganha além deste token. A pergunta que separa os dois casos — a plataforma precisa recuperar o valor? — passa a ser a pergunta padrão para todo segredo novo. Ela é mais útil que "qual hash é mais forte", que é a pergunta que leva a bcrypt numa API.