Como criptografar senhas no Node.js com Argon2, salt e pepper

Por que SHA-256 apenas não serve para senha e como gerar o hash com Argon2, salt e pepper usando o crypto nativo do Node.js, sem instalar biblioteca.

Escrito por André Rafael - Origamid

Curso Back End Node.js

Publicado em · Atualizado em

Quando você vai criptografar senha no Node.js, o cenário que você tem que ter na cabeça é o pior possível: o seu banco de dados vazou. Se as senhas estiverem lá em texto, o atacante entra no seu sistema como qualquer pessoa, e o estrago não para no seu site, porque boa parte das pessoas usa a mesma senha em todo lugar. Por isso a senha nunca é guardada como ela chegou, e sim como um hash, um valor derivado dela que não volta para a senha original. Para gerar esse hash vamos usar o Argon2, que agora já vem no módulo crypto do Node, sem instalar nenhuma biblioteca.

Esquema do hash da senha: a senha '12345678', o salt gerado com randomBytes(16) e o pepper 'segredo' entram no argon2('argon2id', ARGON2_OPTIONS). Ele gera o dk (chave derivada), que vai para o banco na password string argon2id$v=1$norm=NFC$m=65536,t=3,p=4$<salt>$<dk>.

Um detalhe de nome antes de começar. Todo mundo fala em “criptografar a senha”, e eu também falo/falava, mas o que fazemos não é uma criptografia que você consegue desfazer depois com uma chave. A chave derivada (dk) não tem volta. Na hora do login você não descobre a senha que está no banco, você gera o hash de novo com a senha que a pessoa digitou e compara um com o outro.

Por que SHA-256 não serve para senha

Se você já usou o createHash com SHA-256 para guardar o id de uma sessão, você pode acabar pensando em usar o mesmo para a senha. Mas para senha ele não funciona, porque o SHA-256 é um algoritmo rápido. Com o banco vazado em mãos, o atacante consegue testar um volume enorme de senhas em pouco tempo, gerando o SHA-256 de cada uma e procurando qual bate com o que está no seu banco.

Tem um segundo problema, que aparece quando você roda o código:

crypto.js
import { createHash } from 'node:crypto'; const sha = createHash('sha256').update('12345678').digest('hex'); console.log(sha); // sempre o mesmo valor, em qualquer máquina, toda vez que rodar

A mesma senha sempre gera o mesmo hash. Se eu olho um banco vazado e vejo um monte de usuários com exatamente o mesmo hash, já sei que eles têm a mesma senha, e provavelmente é algo como 12345678. Descobri a senha de um, descobri a de todos.

E nem precisa adivinhar. Existem as rainbow tables, que são tabelas prontas com o hash de senhas comuns. Pode ter certeza que o SHA-256 de 12345678 está numa delas, e o atacante só cruza a tabela com o seu banco para descobrir um monte de senhas fracas de uma vez. E senha fraca vai existir no seu site, não adianta. Você obriga a ter número e símbolo, e a pessoa cadastra P@ssw0rd, com o zero no lugar do “o”, ou Fl@m3ngo com o mesmo truque. O que queremos é que, mesmo com uma senha fraca dessas, o hash guardado seja impossível de reconhecer.

bcrypt, scrypt ou Argon2

Para senha a gente usa um algoritmo lento de propósito. A ideia é que, se o banco vazar, testar senha por força bruta demore tanto que simplesmente não valha a pena tentar.

As opções mais conhecidas são três. O bcrypt é antigo e muito usado. O scrypt está no node:crypto há muitos anos. E o Argon2, que venceu a Password Hashing Competition, é o que costuma ser apontado como o mais seguro dos três.

Por muito tempo, usar o Argon2 no Node significava instalar um pacote de terceiro fazendo a ponte com o código em C do algoritmo, e eu prefiro não depender de código de terceiro para uma coisa tão sensível. Por isso o scrypt era a escolha natural. Isso mudou na versão 24.7, quando o argon2 entrou no próprio node:crypto. Se o seu servidor roda o Node 24.7 ou mais novo, não tem motivo para não usar o Argon2, e é ele que vamos usar aqui.

O Argon2 tem três variantes: argon2d, argon2i e argon2id. Para senha, use o argon2id, que é a recomendada, porque junta a resistência das outras duas a ataques diferentes.

Criptografar senha no Node.js com Argon2

A versão mais crua do argon2 usa callback. Ela recebe o nome da variante, um objeto com os parâmetros e o callback, que recebe o erro e o valor gerado. Esse valor a gente chama de dk, de derived key (chave derivada), que é o nome que se dá ao hash gerado por esse tipo de algoritmo.

crypto.js
import { argon2 } from 'node:crypto'; const params = { message: '12345678', nonce: 'abcdefgh', tagLength: 32, parallelism: 4, memory: 65536, passes: 3, }; argon2('argon2id', params, (err, dk) => { console.log(dk.toString('hex')); });

O message é a senha. O nonce é o salt, que vamos ver na próxima seção, e precisa ter pelo menos 8 bytes, senão o Node lança um erro. O tagLength é o tamanho da chave gerada, em bytes. Os outros três definem o custo do algoritmo.

O dk é um Buffer, por isso o toString('hex') para conseguir ler. Rode duas vezes e o resultado é o mesmo, e precisa ser, senão não teria como comparar a senha depois. Troque o nonce para qualquer outra coisa e o hash muda completamente, mesmo com a senha igual. É exatamente esse comportamento que resolve o problema das senhas repetidas.

Dá para transformar o argon2 numa função que retorna promise com o promisify, igual se faz com o randomBytes:

crypto.ts
import { argon2, randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; const randomBytesAsync = promisify(randomBytes); const argon2Async = promisify(argon2);

Salt: a mesma senha com hashes diferentes

O salt é um valor aleatório combinado com a senha para que senhas idênticas resultem em hashes diferentes. Você “tempera” a senha com com o sal (salt). No argon2 ele vai no parâmetro nonce. No teste com callback usei 'abcdefgh' fixo, mas na prática ele é gerado de forma aleatória para cada pessoa, com o randomBytes. Um salt de 16 bytes já é suficiente, e é o tamanho que a própria documentação do Node recomenda.

crypto.ts
const salt = await randomBytesAsync(16); const dk = await argon2Async('argon2id', { message: '12345678', nonce: salt, parallelism: 4, tagLength: 32, memory: 65536, passes: 3, }); console.log(dk.toString('hex'));

Agora, cada vez que você roda, sai um hash diferente para a mesma senha. Duas pessoas com 12345678 ficam com valores sem nenhuma relação no banco, e a rainbow table deixa de servir, porque ela teria que ter sido gerada com aquele salt específico.

Você pode se perguntar como a senha vai ser verificada depois, se o salt é aleatório. Ele é guardado junto com o hash. Sem o salt é impossível gerar de novo o mesmo dk, por isso o que vai para o banco é uma string com os dois, separados por um $:

crypto.ts
const password_hash = `${salt.toString('hex')}$${dk.toString('hex')}`;

Uso hexadecimal de propósito, porque ele só tem caracteres de 0 a f. Assim o $ nunca aparece dentro do salt nem do dk, e fica fácil separar as partes depois. O tamanho também é previsível: os 16 bytes de salt viram 32 caracteres em hexadecimal, e os 32 bytes do dk (256 bits) viram 64.

Parâmetros memory, passes e parallelism do Argon2

O que deixa o Argon2 lento são três parâmetros:

crypto.ts
const ARGON2_OPTIONS = { memory: 2 ** 16, // memória em KiB (64 MiB) passes: 3, // quantas vezes ele passa pela memória parallelism: 4, // quantas linhas de processamento em paralelo }; const dk = await argon2Async('argon2id', { message: '12345678', nonce: salt, tagLength: 32, ...ARGON2_OPTIONS, });

O memory é quanta memória cada hash ocupa, em blocos de 1 KiB. Então 2 ** 16, que é 65536, dá 64 MiB. É isso que torna o Argon2 caro de atacar: quem tenta força bruta com placa de vídeo ou hardware dedicado tem muito poder de processamento, mas não tem tanta memória para cada tentativa. O passes é quantas vezes o algoritmo percorre essa memória, e quanto maior, mais lento. O parallelism é quantas linhas ele processa em paralelo, e o memory precisa ser pelo menos 8 vezes esse valor.

Quanto tempo isso leva? Dá para medir com console.time:

crypto.ts
console.time('argon2'); const dk = await argon2Async('argon2id', { message: '12345678', nonce: salt, tagLength: 32, ...ARGON2_OPTIONS, }); console.timeEnd('argon2');

Na minha máquina, com esses valores, deu por volta de 35 milissegundos. Não queremos que a função termine em 1 milissegundo, mas também não precisa demorar segundos, e algo abaixo de 100 milissegundos é um bom alvo. Só lembre que cada login ocupa os 64 MiB enquanto o hash é gerado, então num servidor pequeno com muitos logins ao mesmo tempo pode valer baixar o memory. O mínimo que a OWASP recomenda para o argon2id é 19 MiB (19456) com 2 passes e parallelism 1.

Se essa parte dos parâmetros ficou complicada, não se preocupe, ela é a menos importante para quem está desenvolvendo o Back End. O que você precisa entender é outra coisa: qualquer mudança nesses parâmetros muda o hash final. Com o salt e a senha fixos, passes: 3 gera um valor e passes: 2 gera outro completamente diferente. Se um dia você decidir aumentar a memória e trocar no código, todas as senhas que já estão no banco deixam de ser validadas. Esses parâmetros são fixos, e mais adiante eles vão para dentro da string salva no banco.

Pepper e normalização da senha

Com salt e Argon2 você já tem um hash de senha bem seguro. Dá para ir um pouco além com mais duas coisas: um pepper e a normalização da senha.

Pepper com o parâmetro secret

O pepper (pimenta, para combinar com o sal) é um segredo único que fica só no seu servidor e entra no cálculo junto com a senha. Diferente do salt, ele nunca é guardado no banco. Com o scrypt ou o bcrypt, o jeito é passar a senha antes por um createHmac com esse segredo, mas o Argon2 já tem um parâmetro para isso, o secret:

crypto.ts
const PEPPER = 'segredo'; const dk = await argon2Async('argon2id', { message: '12345678', nonce: salt, secret: PEPPER, tagLength: 32, ...ARGON2_OPTIONS, });

Se o banco vaza e o pepper não, o atacante não consegue chegar na senha nem com força bruta, porque falta um pedaço da receita. Em compensação, o pepper nunca pode mudar: trocar uma letra dele gera um hash completamente diferente e invalida todas as senhas cadastradas. Em produção ele vira um valor longo, guardado numa variável de ambiente, e não 'segredo' escrito no código.

Normalizar com normalize

Alguns caracteres, principalmente os acentuados, são visualmente iguais e diferentes nos bytes. O “é” pode ser um caractere só ou um “e” seguido de um acento combinado. Se a pessoa cadastrou a senha com uma codificação e o gerenciador de senhas manda a outra no login, o hash gerado é diferente e ela não consegue entrar.

const a = 'é';
const b = 'é';

console.log(a, b);
console.log(a.length, b.length); // 1 2
console.log(a === b);
console.log(a.normalize('NFC') === b.normalize('NFC'));

O b foi escrito com o acento agudo combinado, logo depois do e, para a diferença não se perder quando você copia o código. As duas strings aparecem como “é”, mas o length mostra que a segunda tem dois caracteres, por isso a comparação direta dá false, e depois do normalize('NFC') ela dá true. Existem outras formas de normalização, mas para senha o NFC resolve, e você não precisa se aprofundar nelas.

Isso é uma questão de usabilidade. A normalização é a primeira coisa que acontece com a senha, antes do Argon2:

crypto.ts
const NORM = 'NFC'; const dk = await argon2Async('argon2id', { message: password.normalize(NORM), nonce: salt, secret: PEPPER, tagLength: 32, ...ARGON2_OPTIONS, });

Verificando a senha no login com timingSafeEqual

A pessoa se cadastrou, você gerou a string e salvou no banco. Agora ela tenta logar e você precisa saber se a senha que chegou é a mesma do cadastro. São três passos: fazer o parse da string salva para separar o salt e o dk, gerar o dk de novo com a senha nova e o salt guardado, e comparar os dois.

Antes, vale transformar a geração numa função:

crypto.ts
async function hashPassword(password: string) { const salt = await randomBytesAsync(16); const dk = await argon2Async('argon2id', { message: password.normalize(NORM), nonce: salt, secret: PEPPER, tagLength: 32, ...ARGON2_OPTIONS, }); return `${salt.toString('hex')}$${dk.toString('hex')}`; }

O salt fica dentro da função, porque ele tem que ser novo a cada senha gerada.

O parse quebra a string no $ e transforma cada parte de volta em Buffer com o Buffer.from, indicando que o valor está em hexadecimal:

crypto.ts
function parsePasswordHash(password_hash: string) { const [stored_salt_hex, stored_dk_hex] = password_hash.split('$'); const stored_salt = Buffer.from(stored_salt_hex, 'hex'); const stored_dk = Buffer.from(stored_dk_hex, 'hex'); return { stored_salt, stored_dk }; }

Na verificação, a diferença mais importante é que você não gera salt nenhum. O salt usado é o que veio do banco, e é por isso que o dk sai igual ao original quando a senha está certa:

crypto.ts
import { timingSafeEqual } from 'node:crypto'; async function verifyPassword(password: string, password_hash: string) { const { stored_salt, stored_dk } = parsePasswordHash(password_hash); const dk = await argon2Async('argon2id', { message: password.normalize(NORM), nonce: stored_salt, secret: PEPPER, tagLength: stored_dk.length, ...ARGON2_OPTIONS, }); return timingSafeEqual(dk, stored_dk); }

Por que timingSafeEqual e não uma comparação simples? Porque existem ataques que usam o tempo. Numa comparação comum, a resposta errada pode voltar mais rápido do que a certa, e medindo quanto tempo cada tentativa leva o atacante vai ganhando pistas sobre a senha. O timingSafeEqual compara em tempo constante, então a resposta demora o mesmo para senha certa e errada.

Repare no tagLength: stored_dk.length. O timingSafeEqual lança uma exceção quando os dois Buffers têm tamanhos diferentes, e pedindo ao Argon2 um dk do mesmo tamanho do que está salvo, os dois sempre chegam iguais na comparação.

Para testar, gere o hash de uma senha e verifique com ela e com outra:

crypto.ts
const password = 'P@ssw0rd'; const password_hash = await hashPassword(password); const isTrue = await verifyPassword(password, password_hash); const isFalse = await verifyPassword('12345678', password_hash); console.log({ isTrue, isFalse }); // { isTrue: true, isFalse: false }

Guardando as configurações na password string

Neste ponto já daria para parar, e você teria um hash de senha muito bom. Só que lembra que qualquer mudança nos parâmetros altera o hash? Imagine que, daqui a dois anos, ninguém lembra se as senhas foram geradas com 64 MiB ou 128 MiB de memória. Vale guardar as configurações dentro da própria string.

O formato que eu uso é inspirado no PHC string format, criado na mesma Password Hashing Competition de onde saiu o Argon2. Cada campo continua separado por $, e os parâmetros usam as letras do formato: m para memória, t para passes e p para parallelism.

argon2id$v=1$norm=NFC$m=65536,t=3,p=4$<salt>$<dk>

Primeiro vem o algoritmo, depois a versão, que serve para você mesmo. Se no futuro quiser parâmetros mais fortes, gera as senhas novas como v=2 e o seu código passa a saber comparar as duas versões. Depois vêm a normalização (se ela mudar, o hash muda), os parâmetros separados por vírgula, o salt e o dk. A string não começa com $, porque senão o primeiro item do split seria uma string vazia e você teria que filtrar.

O hashPassword passa a montar a string completa, e o parse fica um pouco maior. A normalização sai do norm= com um replace, e os parâmetros voltam a ser um objeto com o Object.fromEntries, quebrando cada chave=valor no = e convertendo o valor com Number, porque o Argon2 espera números:

crypto.ts
async function hashPassword(password: string) { const salt = await randomBytesAsync(16); const dk = await argon2Async('argon2id', { message: password.normalize(NORM), nonce: salt, secret: PEPPER, tagLength: 32, ...ARGON2_OPTIONS, }); const { memory, passes, parallelism } = ARGON2_OPTIONS; return ( `argon2id$v=1$norm=${NORM}$m=${memory},t=${passes},p=${parallelism}` + `$${salt.toString('hex')}$${dk.toString('hex')}` ); } function parsePasswordHash(password_hash: string) { const [id, v, norm, options, stored_salt_hex, stored_dk_hex] = password_hash.split('$'); const stored_salt = Buffer.from(stored_salt_hex, 'hex'); const stored_dk = Buffer.from(stored_dk_hex, 'hex'); const stored_norm = norm.replace('norm=', ''); const { m, t, p } = Object.fromEntries( options.split(',').map((kv) => { const [k, v] = kv.split('='); return [k, Number(v)]; }), ); const stored_options = { memory: m, passes: t, parallelism: p }; return { stored_options, stored_norm, stored_salt, stored_dk }; }

No verifyPassword, a normalização e os parâmetros passam a vir da string, e não mais das constantes globais:

crypto.ts
async function verifyPassword(password: string, password_hash: string) { const { stored_options, stored_norm, stored_salt, stored_dk } = parsePasswordHash(password_hash); const dk = await argon2Async('argon2id', { message: password.normalize(stored_norm), nonce: stored_salt, secret: PEPPER, tagLength: stored_dk.length, ...stored_options, }); return timingSafeEqual(dk, stored_dk); }

Em segurança nada mudou, isso é apenas para melhorar a usabilidade de quem desenvolve. Você pode pensar que escrever “argon2id” e os parâmetros na string facilita para o atacante, mas não facilita, porque existe a segurança de verdade, que é usar um algoritmo forte mesmo com a pessoa sabendo qual é, e existe a segurança de caixa preta (black box), que só funciona enquanto ninguém sabe o que tem dentro. Segurança por obscuridade geralmente não é a melhor que existe, porque as pessoas vão testando até descobrir. Esconder se você usou Node ou PHP afasta no máximo o atacante bem amador. O que protege a senha é o hash ser forte.

Antes de juntar tudo, vale ver o caminho completo de cada função. No cadastro, a senha normalizada, o salt, o pepper e os parâmetros entram no Argon2, e o que vai para o banco é a password string:

hashPassword(password)


recebe a senha

normalize('NFC')


normaliza a senha

argon2('argon2id', ARGON2_OPTIONS)


gera o dk

password string


junta opções + salt + dk

salt


randomBytes(16)

pepper


segredo do servidor

No login, o salt e os parâmetros vêm da password string, o Argon2 gera o dk de novo e o timingSafeEqual compara com o que estava salvo:

verifyPassword(password, hash)


senha + password string

normalize('NFC')


normaliza a senha

argon2('argon2id', stored_options)


gera o dk de novo

timingSafeEqual


compara dk e stored_dk

parsePasswordHash(hash)


opções + salt + dk do banco

pepper


segredo do servidor

stored_salt


salt do banco

stored_dk


dk do banco

Para usar isso numa API, eu junto tudo numa classe Password, que recebe o pepper no construtor e deixa os tamanhos do salt e do dk como propriedades, para você mudar num lugar só se quiser:

password.ts
import { argon2, randomBytes, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; const randomBytesAsync = promisify(randomBytes); const argon2Async = promisify(argon2); export class Password { PEPPER: string; NORM = 'NFC'; ARGON2_OPTIONS = { memory: 2 ** 16, passes: 3, parallelism: 4, }; DK_LEN = 32; SALT_LEN = 16; constructor(PEPPER: string) { this.PEPPER = PEPPER; } async hash(password: string) { const salt = await randomBytesAsync(this.SALT_LEN); const dk = await argon2Async('argon2id', { message: password.normalize(this.NORM), nonce: salt, secret: this.PEPPER, tagLength: this.DK_LEN, ...this.ARGON2_OPTIONS, }); const { memory, passes, parallelism } = this.ARGON2_OPTIONS; return ( `argon2id$v=1$norm=${this.NORM}$m=${memory},t=${passes},p=${parallelism}` + `$${salt.toString('hex')}$${dk.toString('hex')}` ); } parse(password_hash: string) { const [id, v, norm, options, stored_salt_hex, stored_dk_hex] = password_hash.split('$'); const stored_salt = Buffer.from(stored_salt_hex, 'hex'); const stored_dk = Buffer.from(stored_dk_hex, 'hex'); const stored_norm = norm.replace('norm=', ''); const { m, t, p } = Object.fromEntries( options.split(',').map((kv) => { const [k, v] = kv.split('='); return [k, Number(v)]; }), ); const stored_options = { memory: m, passes: t, parallelism: p }; return { stored_options, stored_norm, stored_salt, stored_dk }; } async verify(password: string, password_hash: string) { const { stored_options, stored_norm, stored_salt, stored_dk } = this.parse(password_hash); const dk = await argon2Async('argon2id', { message: password.normalize(stored_norm), nonce: stored_salt, secret: this.PEPPER, tagLength: stored_dk.length, ...stored_options, }); return timingSafeEqual(dk, stored_dk); } }

O uso fica em duas linhas, uma no cadastro e outra no login:

const pass = new Password('segredo'); const password_hash = await pass.hash('P@ssw0rd'); const isTrue = await pass.verify('P@ssw0rd', password_hash); const isFalse = await pass.verify('12345678', password_hash);

A partir daqui, a segurança das senhas depende de duas coisas que você não pode perder nem trocar: o pepper, que deve ficar guardado fora do banco, e os parâmetros usados para gerar as senhas que já existem. O código pode ser reescrito sempre que necessário, mas, se o pepper se perder, ninguém mais consegue fazer login no sistema.

Esse artigo foi escrito com base na aula 0510 password do curso Back End Node.js

Referências

Artigos relacionados