Rotação de segredos
Parte do Runbook operacional. Comece pela triagem do índice.
Rotação das chaves do auth
Decisões no ADR 0031 e no ADR 0032.
O auth first-party tem três segredos, com consequências muito diferentes quando trocados. Confundir os dois primeiros é o erro caro aqui.
AUTH_JWT_SECRET — rotação sem indisponibilidade
O par Ed25519 que assina o access token é derivado dele. A rotação é a mesma dança de três passos da chave mestra (abaixo):
AUTH_JWT_SECRET_PREVIOUSrecebe o valor antigo;AUTH_JWT_SECRETrecebe o novo. Reinicie a api.- As duas chaves aparecem em
/.well-known/jwks.jsone as duas verificam; só a nova assina. A api emite umWARNuma vez por processo, na primeira verificação ou leitura do JWKS depois do boot (a chave anterior é derivada sob demanda, não no início), enquanto isso durar. - Depois de 15 minutos (o TTL do access token), nenhum token da chave antiga
sobrevive. Remova
AUTH_JWT_SECRET_PREVIOUSe reinicie.
Ninguém é deslogado: os refresh tokens têm hash com AUTH_TOKEN_PEPPER,
não com esta chave, e desde a RN-613
o pepper é obrigatório e nunca emprestado desta chave.
A ordem, se esta instalação é anterior à RN-613: pepper PRIMEIRO, JWT depois.
Antes da RN-613 um pepper não definido caía em silêncio para AUTH_JWT_SECRET — e
os composes de produção e de instalação nem passavam AUTH_TOKEN_PEPPER à
api, então isso era toda instalação compose. Agora a api recusa
subir sem o pepper, com uma mensagem dizendo o que fazer. A migração
que não desloga ninguém:
- Defina
AUTH_TOKEN_PEPPERcom o valor atual deAUTH_JWT_SECRET(copie-o; não gere um novo). Reinicie a api. Os hashes dos tokens são byte a byte o que eram, então todo refresh token e PAT continua funcionando. Oinstall.shfaz este passo sozinho quando encontra o.envanterior na pasta de onde roda, e diz isso no terminal sem imprimir o valor; o Kubernetes já tinhaAUTH_TOKEN_PEPPERnoExternalSecret. - Só então rode a rotação de
AUTH_JWT_SECRETcom os três passos acima. Daqui em diante os dois segredos são independentes, e um pepper igual ao segredo de JWT antigo é o estado esperado, não uma sobra.
Se alguém rotacionar o JWT antes de separar, numa versão anterior à RN-613
essa rotação era uma troca de pepper: todo mundo é deslogado, todo link de
e-mail/reset aberto morre e todo PAT para de autenticar — o logout global
descrito na próxima seção. Não há como desfazer a não ser pondo o valor antigo
de volta como AUTH_TOKEN_PEPPER (o que revive os tokens antigos que não
expiraram, e invalida tudo o que foi emitido no meio). Na RN-613 e depois o
erro não acontece em silêncio: sem pepper a api não sobe. O único
jeito de ainda cair nele é gerar um pepper novo durante a migração
em vez de copiar o segredo de JWT atual — mesmo logout global.
Verificação: apps/api/test/infrastructure/security/ed25519-access-token-issuer.spec.ts
(describe "rotação de chave"); para o pepper,
apps/api/test/infrastructure/security/auth-key-material.spec.ts (describe
"pepperAtual (RN-613)": a recusa no boot e a mensagem dela),
apps/api/test/application/use-cases/auth/rotacao-dos-segredos.spec.ts
(describe "a migração do RN-613": um refresh com hash pelo fallback antigo continua
válido com o pepper definido como o segredo de JWT antigo, e sobrevive à
rotação do JWT) e scripts/dev/install-env.spec.ts (o .env que o instalador
escreve nos dois casos, pelo docker compose config).
AUTH_TOKEN_PEPPER — logout global, sem meio-termo
Esta é a chave HMAC para o hash dos refresh tokens e dos tokens de conta. Trocá-la invalida, tudo de uma vez:
- todo refresh token em circulação — todo mundo é deslogado;
- todo link aberto de verificação de e-mail e de redefinição de senha;
- todo personal access token (PAT) — um runner iniciado com
--tokenpara de autenticar (chaves de dispositivo não são afetadas); - os contadores de lockout: toda conta travada destrava.
As senhas sobrevivem (argon2id, sem pepper): as pessoas entram de novo normalmente. Um
refresh rejeitado assim aparece como refresh_unknown em auth_events,
não como refresh_reuse_detected.
Não há AUTH_TOKEN_PEPPER_PREVIOUS, e essa é uma decisão consciente:
aceitar verificação dupla em todo refresh, para sempre, por um cenário
que acontece uma vez na vida, não compensa. Se você precisar trocá-lo —
suspeita de vazamento do banco, por exemplo — avise as pessoas antes: o sintoma
para o usuário é ser deslogado sem motivo aparente e ver o
link de redefinição dizer "expirado".
A api não deixa de subir com um pepper novo. Ela simplesmente deixa de reconhecer qualquer token antigo. Se o suporte relata "todo mundo foi deslogado ao mesmo tempo", esta variável é o primeiro lugar para olhar — e, numa versão anterior à RN-613 em que ela nunca foi definida,
AUTH_JWT_SECRET. Ela deixa de subir sem um, em produção: veja a ordem de migração acima.
Verificação: apps/api/test/application/use-cases/auth/rotacao-dos-segredos.spec.ts
(RN-597).
BRABO_SERVICE_TOKEN — rotação sem indisponibilidade, dos dois lados
Este é o segredo compartilhado que autentica o tráfego api ↔ engine (RN-035). Ele não tem nada a ver com sessão de usuário: errá-lo não desloga ninguém, quebra a comunicação interna.
A dança é a mesma do AUTH_JWT_SECRET, com a diferença de que ela
roda nas duas cargas — e a ordem importa, porque cada lado manda o
atual e aceita os dois:
BRABO_SERVICE_TOKEN_PREVIOUSrecebe o valor antigo na api e no engine;BRABO_SERVICE_TOKENrecebe o novo nos dois. Reinicie os dois. Em produção a api, o engine e o broker conferem o valor antigo com a mesma regra do novo, então se o valor antigo é o default público ou tem menos de 16 caracteres, cada um deles recusa subir neste passo e nomeia a variável (RN-598, RN-601). Sair de um token fraco, portanto, quer dizer pular o_PREVIOUS, e pagar por isso com a janela de403/401descrita abaixo.- Enquanto os dois estão de pé com a variável nova, o tráfego funciona em qualquer combinação de pods antigos e novos — é isso que torna o rollout seguro no meio do caminho.
- Quando o rollout dos dois Deployments terminou, remova
BRABO_SERVICE_TOKEN_PREVIOUSe reinicie.
Pular o passo 1 e só trocar o valor atual produz 403/401
durante toda a janela em que um pod antigo continua de qualquer lado — o
sintoma do
diagnóstico acima.
Verificação: na api, apps/api/test/infrastructure/security/service-token.spec.ts
(describe "rotação do BRABO_SERVICE_TOKEN") e
apps/api/test/interfaces/engine-service.guard.spec.ts; no engine,
apps/engine/test/engine_web/plugs/verify_service_token_test.exs e
apps/engine/test/engine/runtime_service_token_test.exs (a regra do boot); no
broker, apps/broker/src/config.spec.ts.
Onde as variáveis
_PREVIOUSchegam ao processo. Nos três composes (docker-compose.yml,docker-compose.prod.yml,docker-compose.install.yml) todas estão mapeadas noenvironment:do serviço que as lê, vazias por padrão — definir uma no.enve recriar o serviço basta (RN-595; guardado porscripts/ci/previous-nos-composes.spec.ts, que deriva a lista do código). No Kubernetes elas chegam aos Pods pelo cofre de segredos (AT-220): oExternalSecret(deploy/k8s/base/common/externalsecrets.yaml) puxa o objetobrabointeiro comdataFrom.extract, então um_PREVIOUSque existe lá cai embrabo-secretse um que não existe simplesmente não está lá — sem falha de sync fora de uma rotação. Elas nunca são listadas uma a uma: uma entradadatacuja propriedade falta reprova o sync do Secret inteiro, e um_PREVIOUSausente é o estado normal. De qualquer jeito, confirme dentro do container (printenv) antes de confiar no passo 2.
Fazendo no Kubernetes
A mesma dança, com o objeto brabo no provedor de segredos fazendo as vezes do
.env. No cluster local esse objeto é o Secret brabo que o bootstrap
criou; em staging/prod é a entrada do provedor para onde o SecretStore aponta.
-
Acrescente a chave
_PREVIOUS(valor antigo) e substitua a chave atual (valor novo) no objetobrabo— as duas na mesma mudança. Localmente:kubectl -n brabo patch secret brabo --type merge -p \ '{"stringData":{"AUTH_JWT_SECRET_PREVIOUS":"<old>","AUTH_JWT_SECRET":"<new>"}}' -
Faça o operator sincronizar agora em vez de esperar o
refreshInterval(1h), e confira que a chave chegou:kubectl -n brabo annotate externalsecret brabo-secrets force-sync="$(date +%s)" --overwrite kubectl -n brabo get secret brabo-secrets -o go-template='{{range $k, $v := .data}}{{$k}}{{"\n"}}{{end}}' | grep _PREVIOUS -
Reinicie quem a lê. O
envFromé lido quando o container inicia: o Secret mudar não muda um Pod rodando.apiparaAUTH_JWT_SECRETeCREDENTIALS_MASTER_KEY,apieengineparaBRABO_SERVICE_TOKEN:kubectl -n brabo rollout restart deployment/api deployment/engine kubectl -n brabo exec deploy/api -- printenv AUTH_JWT_SECRET_PREVIOUS -
Aposente-a quando a condição da própria rotação for cumprida (15 min para
AUTH_JWT_SECRET, os dois rollouts terminados paraBRABO_SERVICE_TOKEN,falhas=0e zero pendentes para a chave mestra): remova a chave_PREVIOUSdo objetobrabo, force o sync de novo (passo 2 — a chave some debrabo-secrets, não fica para trás) e reinicie de novo (passo 3). Até o reinício os Pods mantêm o valor antigo em memória, e a api continua avisando que há uma rotação em andamento.kubectl -n brabo patch secret brabo --type json -p \ '[{"op":"remove","path":"/data/AUTH_JWT_SECRET_PREVIOUS"}]'
Verificação: os quatro passos foram exercitados contra o chart do External Secrets
Operator fixado em deploy/k8s/helm/charts.env (0.19.2) num cluster k3d
descartável, com o SecretStore local: a chave apareceu em
brabo-secrets depois do force-sync e sumiu depois da remoção, com o
ExternalSecret continuando SecretSynced. Desde a AT-146 os mesmos quatro passos rodam
toda semana para CREDENTIALS_MASTER_KEY, dentro do ensaio da chave mestra
(make test-rotacao-chave-mestra-k8s, veja
Verificando sem esperar um incidente) — as
outras chaves _PREVIOUS ainda não têm execução automatizada. O que roda em todo PR é
o scripts/ci/previous-nos-composes.spec.ts, que reprova se o
ExternalSecret deixa de usar dataFrom.extract ou lista um _PREVIOUS em
data:.
# gere um valor com entropia suficiente; ele nunca precisa ser digitado
openssl rand -base64 48
Conta travada por lockout
O lockout é curto (30s a 15 minutos) e se resolve sozinho: a janela deslizante esvazia. Não há endpoint de destravamento, de propósito — veja a RN-031. Se você precisa destravar alguém agora:
-- A chave é um HMAC do e-mail, não o e-mail. Ache-a pelo evento recente:
select subject_key, count(*), max(occurred_at)
from auth_events
where kind in ('login_failure', 'login_blocked_user')
and occurred_at > now() - interval '30 minutes'
group by subject_key order by 3 desc;
delete from auth_lockout_hits where bucket_key = '<subject_key>';
Uma redefinição de senha bem-sucedida também destrava a conta.
Verificação: nenhuma para as duas consultas acima — elas foram conferidas contra
o schema por leitura, nunca executadas por um spec. O próprio lockout (a
janela, o contador, o que o destrava) é coberto por
apps/api/test/application/use-cases/auth/lockout.spec.ts.
O rastro nunca é apagado.
auth_lockout_hitsé um contador efêmero;auth_eventsé append-only e sobrevive a tudo, inclusive à remoção do usuário (sem chave estrangeira, de propósito).
Rotação da chave mestra
Decisões no ADR 0027.
CREDENTIALS_MASTER_KEY embrulha as DEKs que cifram os segredos do usuário:
chaves de API de LLM e tokens de git. Ela é rotacionada periodicamente e, obrigatoriamente,
depois de qualquer suspeita de vazamento.
O que está em jogo
Trocar a variável e reiniciar a api com uma chave só torna toda credencial existente ilegível, tudo de uma vez, sem erro de boot — a falha só aparece no primeiro uso, como "não foi possível decifrar", e não há volta a não ser restaurando a chave antiga.
É por isso que a rotação tem três passos, não um. Durante o do meio, as duas chaves coexistem:
| variável | papel |
|---|---|
CREDENTIALS_MASTER_KEY |
chave ATUAL — sempre usada para cifrar |
CREDENTIALS_MASTER_KEY_PREVIOUS |
chave anterior — tentada só quando a atual falha |
Duas tabelas guardam envelopes: user_credentials e
project_git_connections.
Desde a RN-563 cada envelope leva também um
key_id — a impressão digital da chave que o embrulhou. É ele que torna
o progresso do passo 2 respondível em SQL. Duas coisas sobre ele importam às 3h:
- Ele nunca decide se uma linha abre. A leitura continua tentando a chave atual e caindo para a anterior; o AES-GCM autentica, e ele é a autoridade. O rótulo é metadado, e uma linha cujo rótulo discorda do envelope é re-embrulhada corretamente assim mesmo (ADR 0158).
key_id IS NULLquer dizer "escrita antes de essa coluna existir", nunca "na chave atual". Numa instalação anterior a ela, tudo éNULLaté a primeira rotação, e as consultas abaixo contam isso como pendente — que é a resposta honesta.
Pegue a impressão digital atual do próprio log da api; ela imprime uma linha no boot:
kubectl -n brabo logs -l app.kubernetes.io/name=api --tail=200 \
| grep 'chave mestra corrente'
# chave mestra corrente: key_id=4f2b91c0a77e13d5
Antes: dimensione
select 'user_credentials' as tabela, count(*) from user_credentials
union all
select 'project_git_connections', count(*) from project_git_connections;
O re-embrulho é um UPDATE por linha. Milhares de linhas levam segundos; é bom saber a ordem de grandeza antes de começar.
1. Publique as duas chaves
Gere a nova e publique as duas no provedor de segredos, mantendo
a antiga em CREDENTIALS_MASTER_KEY_PREVIOUS:
openssl rand -hex 32 # a chave nova
No cluster local o Secret de origem é criado pelo bootstrap; em staging/prod o valor vai para o provedor de onde o External Secrets lê. Depois reinicie a api para ela carregar as duas:
Publicar
CREDENTIALS_MASTER_KEY_PREVIOUSno objetobrabodo provedor a põe embrabo-secretsno próximo sync (oExternalSecretpuxa o objeto inteiro) — force o sync e confirme que ela chegou como em Fazendo no Kubernetes, depois reinicie. Nos composes é o.envmais recriar a api.
kubectl -n brabo rollout restart deployment/api
kubectl -n brabo rollout status deployment/api
Confirme que a api está em modo de rotação — ela avisa no log, de propósito, e o aviso nomeia as duas impressões digitais, que é contra o que as consultas abaixo comparam:
kubectl -n brabo logs -l app.kubernetes.io/name=api --tail=50 \
| grep CREDENTIALS_MASTER_KEY_PREVIOUS
# ... rotação em andamento (atual key_id=<NEW>, anterior key_id=<OLD>). ...
Daqui em diante nada quebra: um segredo novo já nasce na chave nova, um segredo antigo continua legível pela anterior. Você pode pausar neste estado por horas se precisar — mas não por semanas: aceitar duas chaves dobra a superfície de uma chave vazada, que é exatamente o motivo de você estar rotacionando.
2. Re-embrulhe o acervo
kubectl -n brabo exec deploy/api -- node scripts/rewrap-deks.js
Saída esperada — o script imprime em português, e é isto que ele
de fato escreve (apps/api/src/scripts/rewrap-deks.ts, main):
[rewrap] resultado
user_credentials total=12 re-embrulhados=12 já na chave atual=0 falhas=0
project_git_connections total=3 re-embrulhados=3 já na chave atual=0 falhas=0
[rewrap] concluído. Agora remova CREDENTIALS_MASTER_KEY_PREVIOUS e reinicie a api.
Os contadores: re-embrulhados, já na chave atual e falhas. Há dois outros desfechos.
Quando nada precisou ser re-embrulhado, a última linha é [rewrap] nada a fazer — o acervo já está na chave atual.. Quando alguma linha falhou, cada uma vai para o stderr como
[rewrap] <tabela>#<id>: <motivo>, a execução termina com [rewrap] <N> registro(s) não abriram com nenhuma das duas chaves. NÃO remova CREDENTIALS_MASTER_KEY_PREVIOUS até resolver., e o código de saída é 1.
Propriedades que importam se algo interromper o script:
- Idempotente. Rodá-lo de novo conta as linhas já convertidas como
já na chave atuale não reescreve nada. Interrompido? Rode de novo. - Só o envelope muda. O texto cifrado do segredo fica byte a byte o mesmo, então parar no meio deixa o acervo consistente: parte na chave nova, parte na antiga, e as duas legíveis enquanto o PREVIOUS existir.
falhas > 0bloqueia o passo 3. São linhas que não abrem com nenhuma das chaves — em geral vindas de outro ambiente, ou de uma rotação anterior interrompida com a chave já descartada. O script identifica cada uma por id, e nomeia POR QUE falhou — uma linha de outro ambiente ("embrulhado pela chave<kid>"), uma linha cujo rótulo discorda do envelope ("rótulo incoerente ou registro adulterado"), e uma linha sem rótulo nenhum são três diagnósticos diferentes com três respostas diferentes. Não remova o PREVIOUS: sem ele você perde também o que ainda abria.
Interrompido, e quer saber onde parou? Pergunte ao banco em vez de
rodar o script de novo pelo contador — com <NEW> a impressão digital atual
do log acima:
select 'user_credentials' as tabela, count(*) as pendentes
from user_credentials where key_id is distinct from '<NEW>'
union all
select 'project_git_connections', count(*)
from project_git_connections where key_id is distinct from '<NEW>';
is distinct from, nunca <>: linhas com key_id IS NULL têm de contar como
pendentes, e <> as descartaria em silêncio.
3. Descarte a chave antiga
Só depois de falhas=0 e a consulta acima responder 0 nas duas tabelas. As
duas dizem coisas diferentes e você quer as duas: falhas=0 quer dizer que nada se recusou
a abrir nesta execução, e a consulta quer dizer que nada ficou para trás — inclusive
linhas que uma execução anterior, interrompida, nunca alcançou.
# remova CREDENTIALS_MASTER_KEY_PREVIOUS do provedor, depois
kubectl -n brabo annotate externalsecret brabo-secrets force-sync="$(date +%s)" --overwrite
kubectl -n brabo rollout restart deployment/api
Verifique que o aviso de rotação sumiu do log e que uma credencial existente ainda funciona (o caminho mais direto é a tela de credenciais do projeto, ou qualquer turno de agente que use uma chave de LLM).
Verificando sem esperar um incidente
O ciclo inteiro tem uma verificação nomeada, e ela roda no CI (RN-562):
pnpm --filter api test -- test/scripts/rewrap-deks.spec.ts
Esse spec conduz a sequência desta página contra um Postgres de verdade e as duas
tabelas: cifra com K1, publica K2, re-embrulha, descarta K1, e ainda decifra. Ele
também fixa as duas propriedades em que este procedimento se apoia — idempotência (uma segunda
execução informa re-embrulhados=0) e a linha ilegível sendo contada e nomeada
sem abortar as outras.
Cobertura mais estreita, em memória, das mesmas primitivas — inclusive o caso em que
nenhuma chave funciona, e o caso em que o rótulo key_id mente — mora em
test/infrastructure/security/envelope-encryption.service.spec.ts.
O rewrap também roda em qualquer ambiente: num de teste, o ciclo completo à mão
cabe em poucos minutos.
Já rodou de verdade? Não. A resposta do mantenedor (2026-09-27): esta
rotação nunca foi executada num ambiente real. Então o spec acima
não é uma rede de segurança sob um procedimento praticado — até a AT-146 era a única
prova, e nunca tocou a imagem, o ExternalSecret nem um reinício da api.
O ensaio, no cluster, toda semana (AT-146).
make test-rotacao-chave-mestra-k8s (deploy/k8s/test-rotacao-chave-mestra.sh)
roda os três passos desta página contra o cluster local, com a mesma
mecânica: ele escreve uma credencial de LLM com valor aleatório pela API
e confere que todo envelope abre na chave de hoje; o passo 1 publica a chave
nova e a antiga como _PREVIOUS no Secret de origem brabo (o substituto
local do provedor — nunca o brabo-secrets direto), força o
sync do ExternalSecret, espera o _PREVIOUS chegar, reinicia a api e
exige o aviso de rotação nomeando as duas impressões digitais; o passo 2 exige
linhas pendentes pela consulta acima, roda o node scripts/rewrap-deks.js da imagem
(falhas=0), exige zero pendentes, e roda de novo (nada a fazer);
o passo 3 remove o _PREVIOUS da origem, exige que ele suma
de brabo-secrets depois do sync, reinicia a api, exige a linha de boot
com a impressão digital nova e nenhum aviso de rotação, e confere que todo envelope
abre só com a chave nova — e que a credencial do início decifra
para o mesmo valor (comparado por sha256 dentro do pod; o valor nunca
sai dele). O veredito é o código de saída. Ele é o último alvo do
agendado .github/workflows/propriedades.yml, porque troca um segredo e
reinicia a api, e deixa o cluster na chave nova sem _PREVIOUS
— uma rotação terminada, não uma pela metade. Primeira execução verde: 36502355798
(2026-09-29, 47 s).
O que o ensaio não cobre, declarado: linhas de project_git_connections
(criar uma conexão de git pela API valida um token contra um
provider real, e o cluster da prova não tem saída de rede — a tabela é lida pelo
verificador e pelo rewrap, em geral com 0 linhas; o envelope dela é o mesmo código,
provado nas duas tabelas pelo spec acima), e o provedor de segredos de staging/produção
(aqui a origem é o Secret local que o SecretStore do overlay
lê; o ExternalSecret e o operator são os de verdade). É um
ensaio num cluster descartável, não uma rotação de ambiente real:
"nunca rodou de verdade" continua verdade até alguém rotacionar um.
Interação com o restore
Restaurar um dump num ambiente com outra chave mestra devolve
um banco intacto com credenciais inúteis. O dump leva
as DEKs embrulhadas, não a chave. Se você restaurou um backup de produção num
ambiente de teste e as credenciais não abrem, não é corrupção:
é a chave errada — e desde a RN-563 você pode
confirmar isso numa comparação em vez de por eliminação, lendo o key_id
de qualquer linha e pondo-o ao lado da linha chave mestra corrente no
log de boot da api. Veja
Restore.
É por isso que a chave mestra faz parte do plano de recuperação: um backup de banco sem a chave correspondente não recupera os segredos do usuário.
Quando algo dá errado
| sintoma | causa |
|---|---|
| a api sobe sem aviso de rotação, mas o script exige o PREVIOUS | a variável nunca chegou ao pod; o ESO só ressincroniza a cada refreshInterval (1h) |
falhas igual ao total |
o PREVIOUS publicado não é a chave que embrulhou o acervo |
já na chave atual igual ao total, sem ter rodado antes |
as duas variáveis têm o mesmo valor — o serviço ignora o PREVIOUS nesse caso |
| uma credencial para de funcionar DEPOIS do passo 3 | alguma linha ficou para trás; republique o PREVIOUS imediatamente e rode o script de novo. A consulta de progresso do passo 2 é o que impede isto, e é a checagem a rodar primeiro |
| a consulta de pendentes responde o total inteiro, num banco que ninguém rotacionou ainda | esperado: o key_id é escrito da próxima escrita em diante, então uma instalação anterior à RN-563 o tem NULL em todo lugar até a primeira rotação. NULL conta como pendente de propósito — "não sei qual chave" não é "já na atual" |
o rewrap diz que uma linha está numa chave que não é nem a atual nem a anterior |
a linha veio de outro ambiente — o mais comum é um dump restaurado entre instalações. Veja Interação com o restore abaixo; o key_id da linha contra o do log de boot da api diz de relance |