Skip to main content

Hash seguro de senhas em Go

Escrito por
blog go password hashing

5 de dezembro de 2023

0 minutos de leitura

Credenciais de usuário são as informações necessárias para autenticar a identidade de uma pessoa e conceder acesso a um sistema ou aplicativo. Normalmente, incluem um nome de usuário ou endereço de e-mail e uma senha. Embora um nome de usuário possa ser armazenado como texto simples em um banco de dados, informações confidenciais, como endereços de e-mail e senhas, não devem ser.

Se uma pessoa mal-intencionada conseguir acessar o banco de dados onde você armazena essas informações, você não vai querer que ela obtenha esses dados com facilidade. Em vez disso, é importante garantir que ela não consiga acessá-los, mesmo que faça um grande esforço e use todos os recursos disponíveis. É aí que o hashing se torna importante.

Dando continuidade ao nosso post anterior sobre hashing de senhas em Java, neste artigo vamos abordar o hashing de senhas em Go. Para começar, vamos relembrar alguns conceitos e práticas recomendadas de hashing de senhas.

Use hashing para senhas 

Um hash é uma função matemática que recebe uma entrada — neste caso, uma senha — e gera uma sequência de caracteres de tamanho fixo, geralmente composta por letras e números. O resultado, conhecido como valor de hash ou código hash, é exclusivo para aquela entrada. As funções de hash são projetadas para serem unidirecionais, ou seja, é computacionalmente inviável recuperar a entrada original a partir do valor de hash. Para proteger senhas, elas são convertidas em hashes e armazenadas como valores de hash, em vez de texto simples.

A diferença entre hashing e criptografia é que, enquanto o hashing é unidirecional, a criptografia é bidirecional e permite recuperar o valor original a partir do valor criptografado. A criptografia é comumente usada para dados confidenciais, como e-mails e outras informações de identificação pessoal (PII). Para senhas, porém, o recomendado é usar hashing.

Mas, se o hashing é unidirecional, como verificamos as credenciais de usuário? O processo é simples: criamos um hash da senha em texto simples fornecida pela pessoa e o comparamos com o hash armazenado no banco de dados. Se os hashes forem iguais, a senha está correta. Caso contrário, as credenciais são inválidas. A vantagem do hashing é que nunca precisamos saber a senha real de uma pessoa!

Métodos de ataque

Como mencionamos, é importante garantir que ninguém consiga acessar essas informações, mesmo que faça um grande esforço e use todos os recursos disponíveis. Uma pessoa mal-intencionada pode usar alguns métodos para obter a senha a partir do hash.

Uma pessoa hacker pode ter acesso a uma lista das suas senhas obtida de outras fontes, como contas invadidas. Ela pode usar uma tabela rainbow para tentar quebrar o hash. Se os hashes forem iguais, a senha foi descoberta. Isso também pode envolver ataques de força bruta, nos quais diferentes combinações de senhas são testadas para verificar se o hash gerado corresponde ao hash da senha original.

Embora isso exija recursos de alta velocidade ou grande poder computacional, esses métodos já se mostraram eficazes contra algoritmos de hash mais antigos, como os da família SHA1, considerando o poder computacional disponível atualmente.

Por isso, é extremamente importante usar algoritmos de hash mais recentes e seguir outras práticas recomendadas para garantir que seja impossível quebrar o hash gerado — ou que isso leve várias décadas de tentativas de combinações e permutações.

Práticas recomendadas para hashing

Veja algumas práticas recomendadas para usar métodos modernos de hashing.

Salt

Um salt é um valor gerado aleatoriamente — geralmente dados binários, como bytes aleatórios — que é adicionado à senha durante a criação do hash. Cada senha tem seu próprio salt exclusivo. Isso acrescenta uma camada de segurança, pois a pessoa mal-intencionada precisaria quebrar o salt e o hash. Assim, quebrar o hash se torna um processo em duas etapas, que exige mais recursos.

O tamanho do salt depende do algoritmo de hash utilizado. Nos algoritmos mais recentes, o salt é gerado automaticamente, sem que você precise fornecer um. Para saber mais, consulte esta folha de dicas da OWASP sobre armazenamento de senhas.

Algoritmos de hash

Os algoritmos de hash desempenham um papel fundamental no hashing de senhas. É essencial escolher um algoritmo resistente a tentativas de quebra. Os algoritmos modernos permitem personalizar configurações como o fator de trabalho e o número de iterações, tornando o hash mais difícil de quebrar. Considerando o poder computacional disponível, é recomendável escolher valores mais altos. Como regra geral, quanto maior o fator de trabalho ou o número de iterações, mais difícil será quebrar o hash.

Como prática recomendada, escolha o maior fator de trabalho ou número de iterações que seu sistema consiga suportar sem impacto significativo no desempenho.

O que considerar nos algoritmos de hash

Veja alguns pontos a considerar ao implementar um algoritmo de hash.

Como lidar com colisões de hash

Colisões de hash ocorrem quando duas entradas diferentes geram o mesmo valor de hash. Embora os algoritmos modernos sejam projetados para minimizar a probabilidade de colisões, elas ainda podem ocorrer. Para reduzir esse risco, recomenda-se usar valores de hash mais longos, como SHA-256 ou SHA-512, que oferecem um espaço de saída maior e diminuem a probabilidade de colisão.

Número de iterações

O número de iterações é um fator importante no hashing de senhas. Quanto maior esse número, mais recursos serão necessários para quebrar o hash, o que torna o ataque mais lento. No entanto, é importante equilibrar segurança e desempenho, pois um número excessivo de iterações pode causar atrasos para usuários legítimos durante a autenticação. Recomenda-se escolher um valor que ofereça segurança adequada sem afetar significativamente o desempenho.

Atualize os algoritmos de hash regularmente

Com o avanço da tecnologia e o aumento do poder computacional, novos algoritmos de hash são desenvolvidos, enquanto os mais antigos ficam obsoletos. Isso acontece porque os algoritmos antigos deixam de acompanhar esses avanços e se tornam alvos fáceis para atacantes.

Hashing de senhas em Go

O pacote crypto do Go oferece funções de hash integradas para algoritmos populares, como argon2, scrypt, bcrypt, pbkdf2 e outros. Essas funções são muito úteis para implementar o hashing de senhas. O pacote bcrypt também oferece a função CompareHashAndPassword, que compara senhas em texto simples com senhas em hash e verifica se elas correspondem.

Argon2

Atualmente, o Argon2 é considerado o algoritmo de hash mais seguro, embora isso possa mudar no futuro. Ele tem três variantes: Argon2d, que maximiza a resistência a ataques de quebra com GPU; Argon2i, otimizado para resistir a ataques de canal lateral; e Argon2id, uma combinação dos dois.

A folha de dicas da OWASP sobre armazenamento de senhas recomenda o uso do algoritmo híbrido Argon2id para armazenar senhas. A folha de dicas recomenda os seguintes valores mínimos para o tamanho da memória (m), o número de iterações (t) e o grau de paralelismo (p):

  • m=47104 (46 MiB), t=1, p=1 (não use com Argon2i)

  • m=19456 (19 MiB), t=2, p=1 (não use com Argon2i)

  • m=12288 (12 MiB), t=3, p=1

  • m=9216 (9 MiB), t=4, p=1

  • m=7168 (7 MiB), t=5, p=1

Implementação em Go

Agora, vamos analisar uma implementação de exemplo do Argon2id para gerar um hash a partir de uma senha em texto simples e compará-lo com o hash.

Configuração

Para começar, vamos criar uma struct para armazenar todos os parâmetros de configuração necessários para fazer hashing com Argon2id:

type Argon2idHash struct {
	// time represents the number of 
	// passed over the specified memory.
	time    uint32
	// cpu memory to be used.
	memory  uint32
	// threads for parallelism aspect
	// of the algorithm.
	threads uint8
	// keyLen of the generate hash key.
	keyLen  uint32
	// saltLen the length of the salt used.
	saltLen uint32
}

A struct Argon2idHash contém os seguintes parâmetros: time, memory (custo computacional), threads (paralelismo), keyLen e saltLen. Esses parâmetros são usados para gerar um hash a partir de uma senha em texto simples.

Em seguida, vamos escrever uma função construtora para inicializar a struct com os valores fornecidos.

// NewArgon2idHash constructor function for 
// Argon2idHash.
func NewArgon2idHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2idHash {
	return &Argon2idHash{
		time:    time,
		saltLen: saltLen,
		memory:  memory,
		threads: threads,
		keyLen:  keyLen,
	}
}

Gerar o salt

Para facilitar a geração de valores aleatórios, especialmente de salts, desenvolvemos a função randomSecret. Ela recebe o tamanho desejado para o valor secreto aleatório e usa a função rand.Read para gerá-lo.

func randomSecret(length uint32) ([]byte, error) {
	secret := make([]byte, length)

	_, err := rand.Read(secret)
	if err != nil {
		return nil, err
	}

	return secret, nil
}

Gerar o hash

Em seguida, criamos o método de hashing GenerateHash na struct Argon2idHash. Esse método permite gerar um hash com os valores configurados. Se nenhum salt aleatório for fornecido, geramos um com o mesmo tamanho definido na configuração. Depois, usamos todos os valores configurados para gerar o hash com a função argon2.IDKey. Se a geração for bem-sucedida, retornamos o par de hash e salt.

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (a *Argon2idHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
	var err error
	// If salt is not provided generate a salt of
	// the configured salt length.
	if len(salt) == 0 {
		salt, err = randomSecret(a.saltLen)
	}
	if err != nil {
		return nil, err
	}
	// Generate hash
	hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
	// Return the generated hash and salt used for storage.
	return &HashSalt{Hash: hash, Salt: salt}, nil
}

Comparação de senhas

Para comparar senhas, criamos outro método — Compare — na struct Argon2idHash. Esse método recebe o hash e o salt armazenados, além da senha a ser comparada.

Primeiro, geramos um hash usando a função GenerateHash criada na etapa anterior. Essa função recebe como entradas a senha fornecida e o salt.

Em seguida, comparamos o hash gerado com o armazenado usando a função bytes.Equal. Se os dois hashes forem iguais, encontramos uma correspondência. Caso contrário, o hash armazenado não corresponde à senha fornecida.

// Compare generated hash with store hash.
func (a *Argon2idHash) Compare(hash, salt, password []byte) error {
	// Generate hash for comparison.
	hashSalt, err := a.GenerateHash(password, salt)
	if err != nil {
		return err
	}
	// Compare the generated hash with the stored hash.
	// If they don't match return error.
	if !bytes.Equal(hash, hashSalt.Hash) {
		return errors.New("hash doesn't match")
	}
	return nil
}

Neste exemplo, usamos a função argon2.IDKey para gerar o hash. Como mencionamos, outros pacotes de criptografia do Go têm funções semelhantes, que também podem ser usadas para gerar hashes seguros de senhas, como fizemos neste exemplo.

Scrypt

O Scrypt é o segundo algoritmo de hash da lista. Sua primeira opção deve ser o Argon2id, mas, se ele não estiver disponível, use scrypt como alternativa.

Assim como para o Argon2id, a folha de dicas da OWASP sobre armazenamento de senhas recomenda os seguintes valores para os campos: parâmetro mínimo de custo de CPU/memória (N), tamanho do bloco (r) e grau de paralelismo (p).

  • N = 2^17 (128 MiB), r = 8 (1024 bytes), p = 1

  • N = 2^16 (64 MiB), r = 8 (1024 bytes), p = 2

  • N = 2^15 (32 MiB), r = 8 (1024 bytes), p = 3

  • N = 2^14 (16 MiB), r = 8 (1024 bytes), p = 5

  • N = 2^13 (8 MiB), r = 8 (1024 bytes), p = 10

A documentação do pacote Go para o pacote scrypt traz um exemplo de como gerar uma chave de hash usando scrypt. As operações de geração e comparação devem ser muito parecidas com as implementadas para a struct Argon2idHash.

Bcrypt

O bcrypt deve ser sua última opção. Se for necessário cumprir a norma FIPS 140, use PBKDF2, que abordaremos a seguir. De acordo com a folha de dicas da OWASP sobre armazenamento de senhas, o fator de trabalho deve ser o maior que o desempenho do servidor de verificação permitir, com valor mínimo de 10.

O pacote bcrypt do Go já inclui funções para gerar e comparar hashes. Por isso, não vamos apresentar um exemplo aqui. Consulte a documentação do pacote Go para ver exemplos.

PBKDF2 (compatível com FIPS 140)

De acordo com a folha de dicas da OWASP sobre armazenamento de senhas, o fator de trabalho do PBKDF2 é implementado por meio do número de iterações, que deve variar de acordo com o algoritmo de hash usado internamente.

  • PBKDF2-HMAC-SHA1: 1.300.000 iterações

  • PBKDF2-HMAC-SHA256: 600.000 iterações

  • PBKDF2-HMAC-SHA512: 210.000 iterações

Exemplo de hashing

Como o PBKDF2 é o único algoritmo da nossa lista compatível com FIPS 140, vamos ver como usá-lo para fazer hashing de senhas em Go. Vamos começar.

A função ToKey do pacote pbkdf2, que usaremos para gerar um hash a partir de uma senha em texto simples, é mostrada abaixo:

func Key(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte

Para realizar o hash, você precisará da senha e do salt como fatias de bytes, além do número de iterações e do tamanho desejado para a chave ou o hash gerado. Também é necessária uma função de hash. Neste caso, a senha é a senha em texto simples que queremos transformar em hash, enquanto o salt é um valor gerado aleatoriamente. Você pode configurar o número de iterações e o tamanho da chave. Para a função de hash, usaremos sha3.New256.

Configuração

// PBKDF2Hash used to generate hash
// from plain text password and also
// compare plain text password with
// stored hash.
type PBKDF2Hash struct {
	// itr the number of iterations.
	itr int
	// keyLen the length of the generated key.
	keyLen int
	// saltLen the length of the salt used.
	saltLen int
}

Gerando o hash

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (p *PBKDF2Hash) GenerateHash(password, salt []byte) (*HashSalt, error) {
	var err error
	// If salt is not provided generate a salt of
	// the configured salt length.
	if len(salt) == 0 {
		salt, err = randomSecret(p.saltLen)
	}
	if err != nil {
		return nil, err
	}
	// Generate hash using pbkdf2 exported key method.
	hash := pbkdf2.Key(password, salt, p.itr, p.keyLen, sha3.New256)
	// Return the generated hash and salt used for storage.
	return &HashSalt{Hash: hash, Salt: salt}, nil
}

Comparação de senhas

// Compare generated hash with store hash.
func (p *PBKDF2Hash) Compare(hash, salt, password []byte) error {
	// Generate hash for comparison.
	hashSalt, err := p.GenerateHash(password, salt)
	if err != nil {
		return err
	}
	// Compare the generated hash with the stored hash.
	// If they don't match return error.
	if !bytes.Equal(hash, hashSalt.Hash) {
		return errors.New("hash doesn't match")
	}
	return nil
}

Isso é muito parecido com a nossa implementação de Argon2idHash. Portanto, você pode modificar o exemplo completo de código para usar PBKDF2Hash.

Código completo

Agora, vamos juntar tudo.

Estrutura do projeto

./hashing
|_main.go
|_argon2id.go
|_go.mod
|_go.sum

O arquivo argon2id.go:

package main

import (
	"bytes"
	"context"
	"errors"

	"golang.org/x/crypto/argon2"
)

// HashSalt struct used to store
// generated hash and salt used to
// generate the hash.
type HashSalt struct {
	Hash, Salt []byte
}

type Argon2idHash struct {
	// time represents the number of 
	// passed over the specified memory.
	time    uint32
	// cpu memory to be used.
	memory  uint32
	// threads for parallelism aspect
	// of the algorithm.
	threads uint8
	// keyLen of the generate hash key.
	keyLen  uint32
	// saltLen the length of the salt used.
	saltLen uint32
}

// NewArgon2idHash constructor function for 
// Argon2idHash.
func NewArgon2idHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2idHash {
	return &Argon2idHash{
		time:    time,
		saltLen: saltLen,
		memory:  memory,
		threads: threads,
		keyLen:  keyLen,
	}
}

// GenerateHash using the password and provided salt.
// If not salt value provided fallback to random value
// generated of a given length.
func (a *Argon2idHash) GenerateHash(password, salt []byte) (*HashSalt, error) {
	var err error
	// If salt is not provided generate a salt of
	// the configured salt length.
	if len(salt) == 0 {
		salt, err = randomSecret(a.saltLen)
	}
	if err != nil {
		return nil, err
	}
	// Generate hash
	hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen)
	// Return the generated hash and salt used for storage.
	return &HashSalt{Hash: hash, Salt: salt}, nil
}

// Compare generated hash with store hash.
func (a *Argon2idHash) Compare(hash, salt, password []byte) error {
	// Generate hash for comparison.
	hashSalt, err := a.GenerateHash(password, salt)
	if err != nil {
		return err
	}
	// Compare the generated hash with the stored hash.
	// If they don't match return error.
	if !bytes.Equal(hash, hashSalt.Hash) {
		return errors.New("hash doesn't match")
	}
	return nil
}

O arquivo main.go:

package main

import (
	"context"
	"crypto/rand"

	"fmt"
	"os"
)

func main() {
	password := []byte("super-secret-password")

	argon2IDHash := NewArgon2idHash(1, 32, 64*1024, 32, 256)

	hashSalt, err := argon2IDHash.GenerateHash(password, nil)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	fmt.Println(hashSalt.Hash)
	fmt.Println(hashSalt.Salt)

	err = argon2IDHash.Compare(hashSalt.Hash, hashSalt.Salt, password)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println("argon2IDHash Password and Hash match")
}

func randomSecret(length uint32) ([]byte, error) {
	secret := make([]byte, length)

	_, err := rand.Read(secret)
	if err != nil {
		return nil, err
	}

	return secret, nil
}

Escolha seu algoritmo com sabedoria

Os exemplos acima são uma ótima introdução à implementação de algoritmos seguros de hash para armazenar senhas em Go. Lembre-se de que nem todos os algoritmos de hash são iguais e que você deve escolhê-los com cuidado, considerando suas necessidades e o estado atual da segurança do algoritmo. O que hoje é considerado um algoritmo robusto pode mudar com o tempo, por isso é recomendável reavaliar sua escolha periodicamente.

Além disso, ferramentas SAST como Snyk Code podem ajudar você a identificar algoritmos desatualizados e orientar sobre como atualizá-los, se necessário. Escolha com cuidado o algoritmo de hash de senhas, mantenha-se informado e bom código!