Senha transacional no BankLab: uma decisão de arquitetura antes de ser uma tela
- BankLab — Um laboratório Open Source para engenharia de sistemas financeiros
- BankLab: ZTA MVP com senha transacional
- Senha transacional no BankLab: uma decisão de arquitetura antes de ser uma tela
Índice
- 1. Autenticação não era suficiente
- 2. A primeira decomposição: credencial, autorização e enforcement
- 3. O PIN não é enviado como hash pelo app
- 4. O token de step-up como autorização curta
- 5. O enforcement ficou antes do caso de uso financeiro
- 6. O contrato público não deveria vazar policy interna
- 7. Hash não era o fim da conversa
- 8. Tasks como ferramenta de pensamento
- 9. O que ficou deliberadamente fora
- 10. Resultado arquitetural
- 11. Referências no repositório
Quando comecei a implementar senha transacional no BankLab, a tentação mais óbvia era tratar o assunto como uma funcionalidade simples:
- criar uma tela para cadastrar um PIN;
- salvar um hash no backend;
- pedir esse PIN antes de uma transferência;
- liberar a operação se a senha estiver correta.
Esse caminho resolveria a história de usuário, mas deixaria passar o ponto mais importante: em um sistema financeiro, senha transacional não é só mais um campo de formulário. Ela é um fator de autorização para operações sensíveis.
Por isso, a implementação nasceu menos como uma tarefa de endpoint e mais como uma discussão de arquitetura.
O objetivo era responder algumas perguntas antes de escrever o fluxo final:
- o que pertence à autenticação e o que pertence à autorização sensível?
- a senha transacional deve ir diretamente no endpoint da transferência?
- como evitar que o app mobile conheça regras internas de policy?
- como garantir que uma autorização usada em uma operação não seja reutilizada?
- qual é o limite aceitável para o MVP e o que deve ficar preparado para evoluir?
Essas perguntas levaram a uma separação deliberada do trabalho em backlogs menores, cada um fechando uma parte do raciocínio antes da próxima etapa.
Autenticação não era suficiente
O BankLab já tinha autenticação com JWT, usuário autenticado, papéis, ownership e regras de estado do usuário. Isso respondia bem à pergunta “quem está usando a API?”.
Mas uma transferência interna exige uma pergunta adicional:
além de estar autenticado, este usuário acabou de confirmar uma operação sensível?
Essa diferença foi a base do desenho.
A senha transacional não foi colocada dentro do módulo de autenticação. Ela entrou em um módulo próprio de segurança:
internal/security/ domain/ application/ infrastructure/ delivery/
A separação ficou assim:
authcuida de login, sessão e identidade autenticada;securitycuida de senha transacional, step-up, token curto e enforcement;- o módulo de contas continua cuidando da transferência em si.
Essa decisão evita que a senha transacional vire uma extensão informal do login. Ela passa a ser tratada como um fator de autorização contextual.
A primeira decomposição: credencial, autorização e enforcement
O trabalho foi separado em três partes principais.
A primeira foi a credencial: criação da senha transacional, validação do formato, armazenamento seguro, tentativas inválidas e bloqueio temporário.
A segunda foi a autorização de step-up: validar a senha transacional e emitir um token curto, específico para uma operação sensível.
A terceira foi o enforcement: antes de executar a transferência, a API valida o token de step-up e só então chama o caso de uso financeiro.
Essa separação foi importante porque cada etapa responde a uma responsabilidade diferente.
Criar uma senha transacional não autoriza uma transferência. Validar a senha também não executa a transferência. E executar a transferência não deveria conhecer a senha transacional.
O fluxo final ficou mais ou menos assim:
- Usuário cria uma senha transacional.
- Antes de uma operação sensível, o app pede step-up.
- A API valida a senha transacional.
- A API emite um step-up token curto e de uso único.
- O app envia esse token no endpoint protegido.
- A API consome o token e só então executa a operação.
Essa estrutura parece mais trabalhosa do que enviar o PIN direto na transferência, mas ela compra uma propriedade importante: o endpoint financeiro não recebe a senha transacional. Ele recebe uma autorização curta, escopada e consumível.
O PIN não é enviado como hash pelo app
Uma decisão explícita foi não fazer o mobile enviar hash da senha transacional.
Isso parece contraintuitivo à primeira vista. Se hash é mais seguro no banco, por que não enviar hash pela rede?
Porque, nesse cenário, o hash viraria a própria credencial. Quem capturasse ou extraísse aquele valor poderia reutilizá-lo como segredo. O backend perderia a capacidade de aplicar corretamente o algoritmo de armazenamento, salt, pepper e evoluções futuras.
A decisão foi:
- o mobile envia a senha transacional apenas nos endpoints próprios de segurança;
- a senha trafega sob HTTPS;
- a senha nunca é logada;
- a API é responsável por transformar e comparar a credencial;
- o hash persistido nunca é exposto ao cliente.
Isso preserva o backend como autoridade sobre a política de armazenamento e validação.
O token de step-up como autorização curta
Depois de validar a senha transacional, a API não libera imediatamente a transferência. Ela emite um token de step-up.
Esse token foi desenhado com algumas decisões bem específicas:
- validade curta de 120 segundos;
- uso único;
- JWT assinado pelo backend;
jtipersistido no banco;- vínculo com uma operação lógica;
- consumo atômico antes da execução da operação sensível.
O modelo é híbrido.
O JWT permite validação de assinatura e claims. A persistência do jti permite controlar uso único, consumo e estado do token. Com isso, o token não é apenas “um JWT que ainda não expirou”. Ele precisa existir no backend e ainda estar ativo.
Essa escolha também definiu uma consequência aceita no MVP: se a transferência falhar depois que o enforcement consumir o token, aquele step-up token continua consumido. O usuário precisa gerar uma nova autorização.
É uma decisão conservadora. Ela favorece a garantia de uso único em vez de tentar reaproveitar a autorização em cenários ambíguos de retry.
O enforcement ficou antes do caso de uso financeiro
Outro ponto importante foi onde colocar a validação do step-up.
A transferência interna continua sendo uma regra do módulo de contas. Ela não deveria receber senha transacional, token de step-up ou detalhes de policy.
Por isso, o enforcement foi posicionado na borda da operação sensível:

A delivery de transferência sabe que aquela rota exige step-up. Ela chama o caso de uso de enforcement no módulo de segurança. Se o enforcement permitir, a transferência segue para o caso de uso financeiro.
Essa escolha mantém a operação de negócio limpa. A transferência continua respondendo a perguntas financeiras: contas, saldo, ledger, idempotência, descrição, recibo. O módulo de segurança responde se aquela requisição tem autorização suficiente para chegar até ali.
O contrato público não deveria vazar policy interna
Na primeira versão do desenho, o app mobile pediria autorização enviando uma chave interna:
{
"endpoint_key": "internal_transfer.create",
"transaction_password": "123456"
}Funciona, mas não é um bom contrato público.
internal_transfer.create é uma chave de policy da API. O mobile não deveria conhecer esse nome, porque ele pertence ao backend. O app conhece a operação pública que quer executar: método HTTP e path.
Então o contrato foi revisto para:
{
"method": "POST",
"path": "/accounts/internal-transfers",
"transaction_password": "123456"
}A API passou a resolver internamente:
POST /accounts/internal-transfers -> internal_transfer.create
Essa mudança é pequena no payload, mas importante arquiteturalmente.
Ela cria uma fronteira mais limpa:
- o cliente fala a linguagem pública da API;
- o backend traduz para sua policy interna;
- o token continua sendo emitido e validado contra a policy interna;
- o mobile não fica acoplado a nomes internos.
Esse ajuste aconteceu antes da implementação mobile justamente para evitar que o app nascesse dependente de um detalhe interno do backend.
Hash não era o fim da conversa
A senha transacional é um PIN numérico de 6 dígitos. Mesmo armazenada com bcrypt, ela tem um espaço de busca pequeno.
O bcrypt já gera salt automaticamente. Então o problema não era “falta de salt”. O ponto era outro: em caso de vazamento do banco, um atacante poderia tentar PINs offline contra os hashes.
A decisão foi adicionar um pepper lido do ambiente da API:
TRANSACTION_PASSWORD_PEPPER
O modelo adotado foi:
mac = HMAC-SHA256(key = TRANSACTION_PASSWORD_PEPPER, message = PIN) peppered = base64(mac) hash = bcrypt(peppered)
O pepper não fica no banco. Ele é segredo operacional da aplicação.
Essa decisão melhora o cenário de vazamento isolado do banco. O atacante não tem apenas hashes bcrypt para testar: ele também precisaria do segredo externo usado para derivar a entrada do bcrypt.
Também houve escolhas de escopo:
- o pepper é obrigatório no startup da API;
- deve ter pelo menos 32 caracteres;
- não pode reutilizar
JWT_SECRETnemAPP_TOKEN; - rotação de pepper ficou fora do MVP;
- como o projeto ainda está em desenvolvimento e sem dados reais, não foi criada compatibilidade com hashes antigos sem pepper.
O ponto mais interessante aqui não foi só adicionar uma variável de ambiente. Foi tratar a pergunta corretamente: bcrypt já tinha salt; o que faltava era um pepper para reduzir o impacto de um vazamento do banco.
Tasks como ferramenta de pensamento
Uma parte importante dessa implementação foi usar backlogs não só como lista de tarefas, mas como registro de decisão.
O trabalho foi dividido em blocos:
- fundação ZTA e limites do MVP;
- senha transacional como credencial separada;
- emissão do step-up token;
- enforcement da transferência interna;
- consolidação dos contratos e erros;
- revisão do contrato público para
method+path; - hardening do hash com pepper.
Essa divisão ajudou a evitar uma implementação “grande e misturada”, em que credencial, contrato HTTP, política, token e transferência financeira se misturariam na mesma entrega.
Cada backlog fechou uma pergunta:
- qual é o módulo responsável?
- qual é o contrato público?
- qual é a fronteira com o mobile?
- qual erro o cliente pode tratar?
- qual parte fica fora do MVP?
- qual trade-off está sendo aceito agora?
Esse processo também deixou claro quando uma decisão técnica precisava voltar para discussão. O contrato com endpoint_key, por exemplo, era funcional, mas exposto demais para o cliente. A discussão apareceu antes de cristalizar o mobile em cima dele.
O que ficou deliberadamente fora
Tão importante quanto o que entrou foi o que ficou fora.
O MVP não implementa:
- dispositivo confiável;
- biometria;
- prova de vida;
- score de risco;
- reset completo de senha transacional;
- troca de senha transacional;
- rotação de pepper;
- múltiplos peppers ativos;
- vínculo do step-up token ao payload detalhado da transferência.
Esses pontos não foram ignorados. Eles foram deixados fora de propósito.
O objetivo do primeiro corte era criar uma base coerente: credencial separada, autorização curta, uso único, contrato público limpo, enforcement antes da operação sensível e armazenamento endurecido.
Com essa base, evoluções futuras têm um lugar natural para entrar.
Resultado arquitetural
No fim, a senha transacional do BankLab ficou menos parecida com uma “senha extra” e mais parecida com uma pequena infraestrutura de autorização contextual.
Ela não substitui autenticação. Ela complementa autenticação.
Ela não entra no endpoint financeiro. Ela autoriza uma operação antes dele.
Ela não expõe policy interna ao mobile. O backend faz essa tradução.
Ela não depende apenas de um JWT solto. O token tem jti persistido e consumo único.
Ela não trata hash como resposta final. O armazenamento usa bcrypt com salt automático e entrada derivada com pepper fora do banco.
O mais importante: a implementação não nasceu apenas de código. Ela nasceu de uma sequência de decisões pequenas, documentadas e revisadas.
Esse é o tipo de arquitetura que eu quero exercitar no BankLab: não a arquitetura como desenho bonito depois do fato, mas como prática durante o desenvolvimento.
Deixe uma resposta