Fastway
Voltar ao blog

Como navegadores geram números aleatórios criptograficamente seguros

Rodrigo Krohling
·8 min de leitura

Math.random() não é aleatório. É uma função determinística que produz uma sequência de aparência aleatória a partir de um valor inicial chamado semente. Dê a mesma semente e você recebe a mesma sequência, sempre, para sempre.

Para um dado num jogo, tudo bem. Para qualquer coisa que um atacante ganharia adivinhando, é uma vulnerabilidade com longo histórico de exploração.

O que um PRNG faz de verdade

Um gerador pseudoaleatório mantém um bloco de estado interno e aplica uma função de mistura a ele a cada chamada. Motores JavaScript modernos usam xorshift128+ ou um parente próximo: um punhado de deslocamentos e XORs sobre 128 bits de estado, rápido o bastante para chamar num laço apertado.

A saída passa em testes estatísticos de aleatoriedade. Os valores são uniformemente distribuídos, não correlacionados e sem padrão óbvio. O que eles não são é imprevisíveis, porque a função é pública e o estado é finito.

Dadas saídas consecutivas suficientes, o estado interno pode ser reconstruído — o xorshift128+ já foi resolvido a partir de um número pequeno de valores observados. Uma vez conhecido o estado, toda saída futura também é conhecida. Não há fator de trabalho a vencer; é álgebra.

Isso não é um defeito. O Math.random() foi projetado para velocidade e qualidade estatística, e entrega as duas. Nunca foi projetado para resistir a um adversário, e a especificação nunca afirmou o contrário.

O que torna um CSPRNG diferente

Um gerador criptograficamente seguro acrescenta duas propriedades que o estatístico não tem:

Imprevisibilidade. Dadas todas as saídas até agora, prever o próximo bit tem de ser computacionalmente inviável — nada melhor que cara ou coroa.

Resistência a retrocesso. Se um atacante comprometer o estado interno agora, ele não pode reconstruir as saídas que você produziu antes.

Esses requisitos descartam funções de mistura simples. CSPRNGs são construídos sobre primitivas criptográficas — uma cifra de bloco em modo contador, ou uma construção baseada em hash — onde recuperar o estado significa quebrar a primitiva subjacente.

De onde vem a entropia

O gerador ainda precisa de uma semente, e é aqui que o sistema operacional faz o trabalho de verdade. O kernel mantém um reservatório de entropia alimentado por eventos físicos genuinamente imprevisíveis:

  • Variação de tempo entre interrupções
  • Movimento de mouse e intervalos entre teclas
  • Tempos de E/S de disco e de rede
  • Em CPUs modernas, uma fonte de ruído em hardware (RDSEED no x86) amostrando ruído térmico no silício

O reservatório é continuamente misturado, e o CSPRNG é semeado e ressemeado a partir dele. No navegador você nunca toca em nada disso; você chama uma função e a plataforma te entrega bytes vindos dessa cadeia.

A API

// Um inteiro sem sinal de 32 bits.
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const n = buf[0];

// 32 bytes aleatórios, por exemplo para um token.
const bytes = crypto.getRandomValues(new Uint8Array(32));

// Um UUID v4, já formatado.
const id = crypto.randomUUID();

O crypto.getRandomValues preenche um array tipado no lugar e é síncrono. Está disponível em todo navegador atual, e no Node sob require('crypto') com a mesma semântica.

A armadilha do viés

Ter bytes seguros não basta — você ainda pode jogar a segurança fora ao mapeá-los para um intervalo.

A abordagem óbvia está errada:

// Enviesado.
const indice = bytes[0] % alfabeto.length;

Se o alfabeto tem 62 caracteres e bytes[0] vai de 0 a 255, então 256 não é múltiplo de 62. Valores de 0 a 7 podem ser produzidos por cinco valores de byte diferentes, enquanto de 8 a 61 só por quatro. Os oito primeiros caracteres do seu alfabeto saem cerca de 25% mais vezes que o resto.

Numa senha de 16 caracteres isso é uma redução mensurável do espaço de busca real — não catastrófica, mas totalmente evitável. A correção é amostragem por rejeição: descarte qualquer byte que caia na cauda desigual.

function indiceAleatorio(max) {
  const limite = Math.floor(256 / max) * max;   // maior múltiplo exato
  const buf = new Uint8Array(1);
  let valor;
  do {
    crypto.getRandomValues(buf);
    valor = buf[0];
  } while (valor >= limite);
  return valor % max;
}

O laço parece desperdício e não é — para um alfabeto de 62 caracteres ele rejeita cerca de 3% dos sorteios.

Essa é a diferença entre um gerador seguro em princípio e um seguro na prática, e é por isso que um gerador de senhas vale mais que escrever o seu do zero toda vez.

Como identificar qual você está olhando

Lendo o gerador de outra pessoa, os sinais são rápidos:

  • Math.random() em qualquer ponto do caminho — não é seguro, por mais hash que venha depois. Hash de entrada previsível dá saída previsível.
  • new Date().getTime() como semente — pior. O milissegundo atual é um espaço de busca de alguns milhares de valores para um atacante que saiba aproximadamente quando o token foi emitido.
  • % aplicado direto sobre bytes aleatórios — fonte segura, resultado enviesado.
  • crypto.getRandomValues mais amostragem por rejeição — correto.

Onde isso importa

Use a fonte criptográfica para qualquer coisa cujo valor dependa de ser imprevisível: senhas, tokens de API, identificadores de sessão, links de redefinição de senha, tokens CSRF, códigos de convite e qualquer identificador que conceda acesso por ser conhecido.

O Math.random() continua sendo a escolha certa para embaralhar uma playlist, dar jitter num tempo de retentativa ou escolher uma imagem de exemplo. Ali velocidade importa e sigilo não.

A regra de decisão é uma pergunta: aconteceria algo ruim se alguém previsse este valor? Se sim, a resposta é crypto.getRandomValues. Se não, use o rápido e não pense mais nisso.