Aguiar Dev
28 de outubro de 2026

Como funciona o teclado dinâmico de senhas do banco

Compartilhar:

Sobre o Evento

Sabe aquele tecladinho do app do banco (ou do cartão de crédito) em que cada botão tem dois números, tipo "4 ou 6", "1 ou 2", "3 ou 9", e que muda de ordem toda vez que você abre a tela de login? Eu sempre tive curiosidade de saber como aquilo funciona por trás. Fui pesquisar e achei pouquíssimo material, até o termo era ruim de buscar. Então resolvi montar um projetinho em C#, levar para uma live da comunidade (com o Gabriel trocando ideia comigo) e agora transformar tudo neste post, pra quem prefere ler a ver mais de uma hora de vídeo.

Um aviso antes de começar: uns 80% a 90% do que tem aqui é fundamento. Lista, matriz, dicionário, laço. Os outros 10% são umas "caixinhas pretas" de criptografia que a gente usa pronta da biblioteca do .NET. Como a biblioteca faz a criptografia não importa pro nosso problema, poderia ser qualquer outra. O foco aqui é a lógica do algoritmo.


O que é esse teclado, afinal

Vamos supor que a minha senha é 1234 (senha super segura, eu sei). No teclado dinâmico eu não digito os números, eu clico nos botões que contêm cada número:

Dígito da senha Botão clicado
1 1 ou 2
2 1 ou 2
3 3 ou 9
4 4 ou 6

Repara que pro 1 e pro 2 eu clico no mesmo botão. E por que "dinâmico"? Porque cada vez que você acessa a tela, os números são embaralhados de novo e os pares mudam. Na próxima vez o 1 pode estar com o 8, o 4 com o 0, e assim vai.

Aí vem a pergunta que me pegou: se o servidor só sabe que eu cliquei em "1 ou 2", "1 ou 2", "3 ou 9", "4 ou 6", como ele descobre se a senha está certa? É isso que a gente vai destrinchar.

A visão geral: duas trilhas que se encontram

Antes de entrar em cada passo, guarda essa ideia, porque ela organiza tudo:

  • Trilha do cadastro (acontece uma vez): a senha passa por uma criptografia junto com um valor aleatório (o salt) e vira um hash. É isso que fica gravado.
  • Trilha da sessão (acontece a cada login): os dígitos são embaralhados em pares, cada par ganha um "apelido" criptografado (o token) e o servidor guarda um mapa de qual token é qual par.
  • O encontro (validação): o cliente manda os tokens, o servidor volta deles para os pares, gera todas as senhas possíveis e compara cada uma com o hash do cadastro.

O diagrama que usei na live mostra as cinco cenas desse fluxo:

Diagrama do teclado dinâmico de senhas em cinco cenas: salt e hash, embaralhamento dos grupos, chave da sessão e tokens, mapa da sessão e validação

O código é um console app em .NET 10, bem procedural, sem classes de domínio. Ele faz os dois papéis, cliente e servidor, no mesmo lugar. Num sistema de verdade isso estaria separado entre front-end e uma API, mas aí eu teria que explicar API, requisição, e por aí vai, e o ruído ia atrapalhar o que interessa, que é o algoritmo.


Passo 1: cadastro com salt e hash

Primeira regra: o servidor nunca guarda a senha 1234. Ele guarda o resultado de uma criptografia dela, o que a gente chama de hash. Um hash tem duas propriedades que importam aqui: a mesma entrada sempre gera o mesmo resultado, e não dá pra voltar do resultado para a senha original. É uma via de mão única.

Só que tem um problema: se duas pessoas usam 1234, as duas teriam o mesmo hash. E existem tabelas prontas com o hash de senhas comuns (as famosas rainbow tables). Pra resolver isso entra o salt: um número aleatório bem grandão, gerado no cadastro, que é misturado à senha antes do hash. Senha igual com salt diferente gera hash diferente.

// Servidor: a senha cadastrada fica guardada só como hash + salt.
var salt = RandomNumberGenerator.GetBytes(16);

// Sem o salt, a mesma senha sempre geraria o mesmo hash.
var hashCadastrado = HashSenha(senhaCadastrada, salt);

E o HashSenha usa o PBKDF2, que já vem pronto no .NET:

static byte[] HashSenha(string senha, byte[] salt) =>
    Rfc2898DeriveBytes.Pbkdf2(senha, salt, IteracoesPbkdf2, HashAlgorithmName.SHA256, 32);

Não se apega muito ao fato de ser um byte[]. Pensa que é só mais um tipo de dado, um "número" grandão. O que importa: o PBKDF2 repete a criptografia 100.000 vezes, e essa lentidão é proposital. Ela deixa cada tentativa de adivinhar a senha mais cara pra quem está atacando.

No banco de dados ficam o hash e o salt. O salt não é segredo, ele precisa estar lá porque a gente vai precisar dele na validação (você já vai entender por quê).

Passo 2: embaralhando os dígitos em pares

Agora a trilha da sessão. Toda vez que alguém abre a tela de login, o servidor pega os dígitos de 0 a 9 e embaralha. Eu usei um algoritmo chamado Fisher-Yates, que percorre a lista de trás pra frente: em cada posição i, sorteia um índice j entre 0 e i e troca os dois de lugar.

var digitos = Enumerable.Range(0, 10).ToArray();

for (var i = digitos.Length - 1; i > 0; i--)
{
    var j = RandomNumberGenerator.GetInt32(i + 1);
    (digitos[i], digitos[j]) = (digitos[j], digitos[i]);
}

Por que Fisher-Yates? Porque ele garante que todas as 3.628.800 ordens possíveis (10!) tenham a mesma chance de sair. Embaralhamento "caseiro" costuma favorecer algumas ordens sem você perceber. Mas, sendo sincero, pro entendimento do teclado a ideia é só uma: embaralhar os números, ponto. E é um ótimo exercício pra implementar na mão se você está estudando.

Repara também que usei RandomNumberGenerator, e não Random. O Random é previsível, e se o embaralhamento fosse previsível, daria pra deduzir o layout das próximas sessões.

Depois de embaralhar, a gente quebra a lista em pares com o Chunk(2) e ordena cada par:

/* - [9, 4, 2, 7, 0, 5, 1, 8, 6, 3] <- lista original
   - [9,4], [2,7], [0,5], [1,8], [6,3] <- lista de listas com dois números
   - [4,9], [2,7], [0,5], [1,8], [3,6] <- ordena os números de cada lista */

return digitos.Chunk(2).Select(grupo => grupo.Order().ToArray()).ToArray();

Dez números, de dois em dois, dão cinco pares. Ou seja, saímos de uma lista e chegamos numa matriz, uma lista de listas. A ordenação dentro do par (o 9,4 vira 4,9) parece detalhe, mas faz diferença no próximo passo: ela garante que o mesmo par sempre tenha a mesma "forma", e aí o token gerado pra ele é sempre o mesmo dentro da sessão.

Esse trechinho usa LINQ, que é aquela mão na roda do C# pra trabalhar com coleções. Use sem medo, mas não deixa virar bengala: entenda minimamente o que ele está fazendo por baixo dos panos.

Passo 3: chave da sessão e tokens

Aqui é onde mora a parte mais esperta. O servidor gera uma chave da sessão: mais um valor aleatório (32 bytes), secreto, que só ele conhece e que é criado de novo a cada login.

static byte[] GerarChaveSessao() => RandomNumberGenerator.GetBytes(32);

Com essa chave, ele criptografa cada par (não a senha, presta atenção nisso) e gera um token:

static string GerarToken(byte[] chaveSessao, int[] grupo)
{
    var dados = Encoding.UTF8.GetBytes(string.Join(",", grupo));
    return Convert.ToHexStringLower(HMACSHA256.HashData(chaveSessao, dados))[..16];
}

O par [4, 9] vira o texto "4,9", que passa por um HMAC-SHA256 (um hash calculado com uma chave) e vira algo como a3f9.... O [..16] só pega os 16 primeiros caracteres pra ficar curto e legível na demo.

Um ponto importante: a criptografia é aplicada ao par inteiro, não a cada número separado. Se cada dígito tivesse o seu próprio token, ficaria muito mais fácil achar um jeito de reverter. E, sem a chave da sessão, ninguém consegue descobrir quais dígitos estão por trás de um token.

Isso aqui me confundiu quando eu estava estudando, então vale separar bem as duas coisas aleatórias que apareceram até agora:

Salt Chave da sessão
Quando é gerado No cadastro da senha A cada login
Duração Permanente Descartável
É secreto? Não, fica gravado junto com o hash Sim, só o servidor conhece
Pra que serve Criptografar a senha Criptografar os pares (gerar os tokens)

Mesmo espírito (um aleatório que deixa a saída imprevisível), papéis diferentes. São dois caminhos de criptografia independentes.

Passo 4: o mapa da sessão e o que viaja pela rede

Com os tokens prontos, o servidor monta um mapa: um dicionário que diz qual token corresponde a qual par.

static Dictionary<string, int[]> CriarMapaSessao(string[] tokens, int[][] grupos) =>
    tokens.Zip(grupos).ToDictionary(par => par.First, par => par.Second);

O Zip é daqueles métodos do LINQ que vale saber que existe: ele "amarra" duas listas, o primeiro item de uma com o primeiro da outra, o segundo com o segundo, e assim por diante. Cinco tokens, cinco pares, cinco entradas no dicionário.

Esse mapa fica só no servidor, associado àquela sessão. E agora vem o pulo do gato: quando eu clico nos botões e confirmo, o que vai pro servidor não é 1234, e nem os pares [1,2] [1,2] [3,9] [4,6]. O que viaja pela rede são só os tokens:

7c21...
7c21...
e04b...
91d8...

Pra quem interceptou, isso é um monte de letra e número sem significado. O servidor, por outro lado, tem o mapa e consegue voltar de cada token pro par:

static int[][]? ReverterTokens(string[] tokens, Dictionary<string, int[]> mapaSessao)
{
    var grupos = new int[tokens.Length][];

    for (var i = 0; i < tokens.Length; i++)
    {
        if (!mapaSessao.TryGetValue(tokens[i], out var grupo))
            return null;

        grupos[i] = grupo;
    }

    return grupos;
}

É basicamente um laço fazendo busca no dicionário: "com base nessa chave, me fala qual é o par". Se chegar um token que não existe no mapa (sessão expirada, token de outra sessão ou inventado), a tentativa já morre aqui.

Mas repara: até aqui o servidor só sabe quais pares foram clicados. Ele ainda não sabe se a senha está certa.

Passo 5: validação, onde as trilhas se encontram

Agora o servidor tem os pares que o usuário escolheu, na ordem. A pergunta é: entre todas as senhas que esses pares poderiam formar, alguma é a senha cadastrada?

Pra responder, ele gera todas as combinações possíveis, o que em matemática se chama produto cartesiano:

static List<string> GerarCombinacoes(int[][] grupos)
{
    var candidatas = new List<string> { "" };

    foreach (var grupo in grupos)
    {
        candidatas = candidatas
            .SelectMany(prefixo => grupo.Select(digito => prefixo + digito))
            .ToList();
    }

    return candidatas;
}

A lista começa com um texto vazio só pra primeira volta do laço ter um prefixo pra estender. A cada par, a lista dobra de tamanho. Na live, com os pares [1,3] e [2,9] nas duas primeiras posições, ficou assim:

Depois do par Candidatas
[1,3] 1, 3
[2,9] 12, 19, 32, 39
3º par 8 candidatas
4º par 16 candidatas

Com 4 posições e 2 dígitos por botão, são 2 × 2 × 2 × 2 = 16 combinações. Esse é um tipo de operação que a gente não faz muito no dia a dia, mas é ótimo pra exercitar lógica. Recomendo tentar fazer na unha.

E agora a parte que eu até expliquei meio torto na live, então vou deixar bem claro aqui: o servidor não descriptografa a senha gravada. Isso nem é possível com hash, e mesmo se fosse, mexer com a senha real em algum momento seria um ponto de fragilidade. O que ele faz é o caminho inverso: pega cada uma das 16 combinações, aplica a mesma criptografia do cadastro, com o mesmo salt, e compara o resultado com o hash gravado. É por isso que o salt precisava estar guardado lá no passo 1.

return combinacoes
    .Select(candidata =>
    {
        var hash = HashSenha(candidata, salt);
        return (candidata, hash, CryptographicOperations.FixedTimeEquals(hash, hashCadastrado));
    })
    .ToList();

Se alguma das 16 bater, a senha está certa. Se nenhuma bater, o usuário errou algum botão. Simples assim.

Um detalhe: a comparação usa FixedTimeEquals, que leva sempre o mesmo tempo, dê igual ou diferente. Uma comparação comum pode terminar mais rápido quando os primeiros bytes já não batem, e um atacante esperto consegue medir isso (o tal timing attack).

A pegadinha: o teclado valida pares, não dígitos

Esse é o ponto central pra entender a lógica, e o que mais gera dúvida. Como cada botão carrega dois dígitos, digitar 1234 ou 1233 manda exatamente os mesmos tokens se o 3 e o 4 estiverem no mesmo botão. O servidor não tem como diferenciar as duas. Na prática, o teclado valida a sequência de botões, não os dígitos.

Outro exemplo: se a senha cadastrada fosse 1230 e o 0 estivesse no mesmo botão que o 8, quem "digitasse" 1238 seria aceito.

E pensa no hash cadastrado: ele não contém par nenhum. É só o alvo da comparação. As combinações vêm dos pares que o usuário enviou, e a senha cadastrada só aparece entre elas se cada dígito dela estiver, posição por posição, nos botões que a pessoa clicou. Errou um botão? Nenhuma das 16 combinações vai bater. Foi exatamente o que eu mostrei na demo trocando o último botão por "0 ou 5": a tabela inteira deu "não".

E se alguém capturar os tokens? (replay)

Depois da validação, o servidor apaga o mapa da sessão. No próximo login, tudo é gerado de novo: novo embaralhamento, nova chave, novos tokens, novo mapa.

Então imagina que um atacante interceptou os tokens que você mandou e tenta reenviá-los depois (isso é o ataque de replay). Esses tokens simplesmente não existem no mapa da sessão nova, o ReverterTokens devolve null e a tentativa é bloqueada. Na demo eu mostro isso no final: o mesmo par "1 ou 8" tem um token na sessão original e um token completamente diferente numa nova sessão.

E o keylogger, aquele programinha que grava o que você digita? Você não digitou nada, você clicou. E mesmo que alguém descubra os botões que você clicou, na próxima vez o teclado já é outro.


Limites da técnica

Seria desonesto vender isso como bala de prata, então vamos aos limites:

  • O espaço de senhas diminui. Como dois dígitos viram o mesmo botão, várias senhas diferentes passam a ser aceitas pelos mesmos cliques.
  • O custo cresce rápido. Cada posição a mais dobra o número de hashes que o servidor calcula: 4 dígitos dão 16, 6 dão 64, 8 dão 256. Com PBKDF2, que é lento de propósito, isso pesa. Na minha máquina, 16 hashes levaram uns 190 ms.
  • Precisa de outras camadas. Na live o Gabriel lembrou bem do limite de tentativas: errou algumas vezes, bloqueou. Rate limiting, bloqueio por tentativas, biometria, limite de transferência por rede Wi-Fi... a segurança de verdade é feita de várias camadas, e o teclado dinâmico é só uma delas. Afinal, o Brasil não é pra amadores.
  • Não protege de quem olha por cima do seu ombro. Quem vê a tela e os seus cliques enxerga os botões escolhidos.
  • Os números da demo são de demo. Usei 100.000 iterações no PBKDF2 pra rodar rápido. A OWASP recomenda 600.000 ou mais pra PBKDF2 com SHA-256, e pra sistemas novos o Argon2id ou o bcrypt costumam ser preferidos.

Vale dizer também: o artigo do Felippe Regazio que usei de referência explica a ideia geral, mas não detalha o embaralhamento nem os algoritmos de hash. As escolhas do projeto (Fisher-Yates, HMAC-SHA256 nos tokens, PBKDF2 na senha) são minhas, pra fins didáticos. Cada banco deve fazer de um jeito um pouquinho diferente.

Rodando o projeto

O código completo e todo comentado está no repositório do GitHub. Com o .NET 10 SDK instalado:

dotnet run --project src/TecladoDinamicoSenhas

Use as setas pra navegar, ENTER pra adicionar o botão, BACKSPACE pra apagar o último e >> CONFIRMAR << pra enviar. A senha cadastrada é 1234. O app pausa entre as etapas e mostra tudo: o hash cadastrado, o mapa da sessão, os tokens que "viajaram", a reversão, a tabela com as 16 combinações e o teste de replay.

Minha sugestão: rode em modo debug, coloque um breakpoint em GerarCombinacoes e vá apertando F10 pra ver a lista de candidatas dobrando a cada volta. É aí que a ficha cai.

Fechando

No fim das contas, o teclado dinâmico é uma combinação de coisas que você provavelmente já conhece: embaralhar uma lista, quebrar em pares, montar um dicionário, gerar combinações e comparar valores. O que é diferente é a lógica de como essas peças se encaixam: o cliente nunca manda a senha, o servidor nunca descriptografa nada, e tudo o que é gerado numa sessão morre com ela.

Se você ainda está pegando os fundamentos, as playlists de C# básico e de Programação Orientada a Objetos no canal dão a bagagem pra entender tudo isso aqui. E se quiser trocar ideia sobre esse ou outros temas, cola na comunidade. As lives são quase um grupo de estudo: a ideia é aprender junto.

Participantes

Tiago Aguiar

Gabriel Castro

Compartilhar: