Skip to main content

Hashing seguro de contraseñas en Go

Escrito por
blog go password hashing

5 de diciembre de 2023

0 minutos de lectura

Las credenciales de usuario son la información necesaria para autenticar la identidad de una persona y darle acceso a un sistema o una aplicación. Por lo general, incluyen un nombre de usuario o una dirección de correo electrónico y una contraseña. Aunque el nombre de usuario se puede almacenar como texto sin formato en una base de datos, la información confidencial, como las direcciones de correo electrónico o las contraseñas, no debería almacenarse así.

Si un actor malicioso obtiene acceso a la base de datos donde almacenas esta información, no quieres ponérsela fácilmente a su alcance. En cambio, debes asegurarte de que no pueda acceder a ella, incluso si hace un esfuerzo decidido y utiliza todos los recursos disponibles. Aquí es donde el hashing cobra importancia.

Como continuación de nuestra publicación anterior sobre hashing de contraseñas en Java, en esta publicación hablaremos del hashing de contraseñas en Go. Empecemos por repasar algunos conceptos y prácticas recomendadas del hashing de contraseñas.

Usa hashing para las contraseñas 

Un hash es una función matemática que toma una entrada (en este caso, una contraseña) y genera una cadena de caracteres de tamaño fijo, que suele ser una combinación de letras y números. El resultado, conocido como valor hash o código hash, es único para cada entrada. Las funciones hash están diseñadas para ser unidireccionales, lo que significa que es computacionalmente inviable reconstruir la entrada original a partir del valor hash. Para almacenar contraseñas de forma segura, se les aplica hashing y se guardan como valores hash en lugar de texto sin formato.

La diferencia entre hashing y cifrado es que, mientras que el hashing es una función unidireccional, el cifrado es una función bidireccional que permite recuperar el valor original a partir del valor cifrado. El cifrado se usa comúnmente para datos confidenciales, como el correo electrónico y otros datos de identificación personal, pero para las contraseñas se recomienda el hashing.

Pero, si el hashing es una función unidireccional, ¿cómo verificamos las credenciales de usuario? El proceso es sencillo: generamos un hash de la contraseña en texto sin formato que proporciona el usuario y lo comparamos con el hash almacenado en nuestra base de datos. Si los hashes coinciden, la contraseña es correcta. Si no coinciden, las credenciales son incorrectas. La ventaja del hashing es que nunca necesitamos conocer la contraseña real del usuario.

Métodos de ataque

Antes dijimos que debes asegurarte de que no puedan acceder a esta información, incluso si hacen un esfuerzo decidido y utilizan todos los recursos disponibles. Un actor malicioso puede usar varios métodos para obtener la contraseña a partir del hash.

Un hacker podría tener acceso a una lista de tus contraseñas obtenida de otras fuentes, como cuentas vulneradas. Puede usar una tabla rainbow para intentar descifrar el hash. Si los hashes coinciden, habrá descifrado la contraseña. También puede recurrir a ataques de fuerza bruta, en los que prueba distintas combinaciones de contraseñas para ver si el hash de la contraseña generada coincide con el de la contraseña original.

Aunque esto requiere recursos de alta velocidad o una gran capacidad de cómputo, estos métodos han demostrado ser eficaces contra algoritmos de hashing antiguos, como los de la familia SHA1, dada la capacidad de cómputo disponible actualmente.

Por eso, es sumamente importante usar algoritmos de hashing más recientes y otras prácticas recomendadas para asegurarse de que sea imposible descifrar el hash generado, o que hacerlo tome varias décadas de permutaciones y combinaciones.

Prácticas recomendadas para el hashing

Estas son algunas prácticas recomendadas para usar métodos modernos de hashing.

Salting

Un valor salt es un valor generado aleatoriamente (por lo general, datos binarios aleatorios, como bytes) que se agrega a la contraseña al crear un hash. Cada contraseña tiene su propio salt único. Esto brinda una capa adicional de seguridad, ya que el actor malicioso tendría que descifrar el salt y el hash. Por lo tanto, descifrar el hash se convierte en un proceso de dos pasos que requiere más recursos.

La longitud del salt depende del algoritmo de hashing utilizado. En los algoritmos más recientes, el salt se genera automáticamente, por lo que no es necesario proporcionarlo. Para obtener más información, consulta esta hoja de referencia de OWASP sobre el almacenamiento de contraseñas.

Algoritmos de hashing

Los algoritmos de hashing desempeñan un papel crucial en el hashing de contraseñas. Es esencial elegir un algoritmo resistente al descifrado. Los algoritmos de hashing modernos permiten personalizar ajustes como el factor de trabajo y las iteraciones de hashing, lo que hace que el hash sea más difícil de descifrar. Teniendo en cuenta la capacidad de cómputo disponible, se recomienda elegir valores más altos. Como regla general, cuanto mayor sea el factor de trabajo o el número de iteraciones, más difícil será descifrar el hash.

Como práctica recomendada, selecciona el valor más alto posible para el factor de trabajo o el número de iteraciones que tu sistema pueda manejar sin un impacto significativo en el rendimiento.

Consideraciones sobre los algoritmos hash

Estos son algunos aspectos que debes tener en cuenta al implementar un algoritmo hash.

Gestión de colisiones de hash

Las colisiones de hash se producen cuando dos entradas distintas generan el mismo valor hash. Aunque los algoritmos de hashing modernos están diseñados para minimizar la probabilidad de que ocurran, las colisiones siguen siendo posibles. Para mitigar este riesgo, se recomienda usar valores hash más largos, como SHA-256 o SHA-512, que ofrecen un espacio de salida mayor y reducen la probabilidad de colisiones.

Número de iteraciones

El número de iteraciones es un factor importante para el hashing de contraseñas. Cuanto mayor sea, más recursos se necesitarán para descifrar el hash, lo que ralentizará el ataque. Sin embargo, es importante encontrar un equilibrio entre la seguridad y el rendimiento, ya que demasiadas iteraciones pueden generar demoras para los usuarios legítimos durante el proceso de autenticación. Se recomienda usar un valor que brinde seguridad adecuada sin afectar significativamente el rendimiento.

Actualiza los algoritmos de hashing con regularidad

A medida que avanza la tecnología y aumenta la capacidad de cómputo, se desarrollan nuevos algoritmos de hashing y se dejan de usar los antiguos. Esto se debe a que los algoritmos antiguos ya no pueden seguir el ritmo de estos avances y se convierten en blancos fáciles para los atacantes.

Hashing de contraseñas en Go

El paquete crypto de Go incluye funciones de hashing para algoritmos populares como argon2, scrypt, bcrypt, pbkdf2 y otros. Estas funciones son muy útiles para implementar la funcionalidad de hashing de contraseñas. El paquete bcrypt incluso incluye una función CompareHashAndPassword para comparar contraseñas en texto sin formato con contraseñas con hash y comprobar si coinciden.

Argon2

Actualmente, Argon2 se considera el algoritmo de hashing más seguro (aunque esto podría cambiar en el futuro). Tiene tres variantes: Argon2d, que maximiza la resistencia a los ataques de descifrado con GPU; Argon2i, optimizado para resistir ataques de canal lateral; y Argon2id, que combina ambos enfoques.

La hoja de referencia de OWASP sobre el almacenamiento de contraseñas recomienda usar el algoritmo híbrido Argon2id para almacenar contraseñas. La hoja de referencia recomienda los siguientes valores mínimos para el tamaño de memoria (m), el número mínimo de iteraciones (t) y el grado de paralelismo (p):

  • m=47104 (46 MiB), t=1, p=1 (No usar con Argon2i)

  • m=19456 (19 MiB), t=2, p=1 (No usar con 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

Implementación en Go

Veamos ahora un ejemplo de implementación de Argon2id para generar un hash a partir de una contraseña en texto sin formato y compararlo con el hash.

Configuración

Para empezar, crearemos una estructura que almacene todos los parámetros de configuración necesarios para aplicar hashing con 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
}

La estructura Argon2idHash contiene los siguientes parámetros: time, memory (costo de cómputo), threads (paralelismo), keyLen y saltLen. Estos parámetros se usan para generar un hash a partir de una contraseña en texto sin formato.

A continuación, escribiremos una función constructora para inicializar la estructura con los valores proporcionados.

// 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,
	}
}

Generación del salt

Para facilitar la generación de valores aleatorios, especialmente la del salt, desarrollamos la función randomSecret. Esta función acepta la longitud deseada del valor secreto aleatorio y usa la función rand.Read para generarlo.

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

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

	return secret, nil
}

Generación del hash

A continuación, creamos el método de hashing GenerateHash en la estructura Argon2idHash. Este método nos permite generar un hash con los valores configurados. Si no se proporciona un salt aleatorio, generamos uno con la misma longitud que el salt configurado. Luego, usamos todos los valores configurados para generar el hash con la función argon2.IDKey. Si el hash se genera correctamente, devolvemos el par de hash y 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
}

Comparación de contraseñas

Para comparar contraseñas, creamos otro método, Compare, en la estructura Argon2idHash. Este método recibe el hash y el salt almacenados, junto con la contraseña que se va a comparar.

Primero, generamos un hash con la función GenerateHash que creamos en el paso anterior. Esta función recibe la contraseña y el salt proporcionados como entradas.

A continuación, comparamos el hash generado con el hash almacenado mediante la función bytes.Equal. Si los dos hashes son iguales, coinciden. Si no, significa que el hash almacenado no corresponde a la contraseña proporcionada.

// 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
}

En este ejemplo, usamos la función argon2.IDKey para generar el hash. Como mencionamos antes, otros paquetes de criptografía de Go tienen funciones similares que se pueden usar para generar hashes seguros de contraseñas, como el que generamos en este ejemplo.

Scrypt

Scrypt ocupa el segundo lugar en la lista de algoritmos de hashing. Argon2id debería ser tu primera opción; si no está disponible, scrypt debería ser tu alternativa.

Al igual que con Argon2id, la hoja de referencia de OWASP sobre el almacenamiento de contraseñas recomienda los siguientes valores para los campos: parámetro mínimo de costo de CPU y memoria (N), tamaño de bloque (r) y grado 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

La documentación del paquete de Go para el paquete scrypt incluye un ejemplo de cómo generar una clave hash con scrypt. Las operaciones para generar y comparar deberían ser muy similares a las que implementamos para la estructura Argon2idHash.

Bcrypt

bcrypt debería ser tu último recurso, al igual que PBKDF2 (que veremos a continuación) si necesitas cumplir con FIPS-140. Según la hoja de referencia de OWASP sobre el almacenamiento de contraseñas, el factor de trabajo debe ser lo más alto posible sin superar lo que permita el rendimiento del servidor de verificación, con un mínimo de 10.

El paquete bcrypt de Go incluye funciones para generar y comparar hashes. Por eso, no veremos un ejemplo aquí. Puedes consultar la documentación del paquete de Go para ver ejemplos.

PBKDF2 (compatible con FIPS 140)

Según la hoja de referencia de OWASP sobre el almacenamiento de contraseñas, el factor de trabajo de PBKDF2 se implementa mediante un número de iteraciones que debe ajustarse según el algoritmo de hashing interno utilizado.

  • PBKDF2-HMAC-SHA1: 1,300,000 iteraciones

  • PBKDF2-HMAC-SHA256: 600,000 iteraciones

  • PBKDF2-HMAC-SHA512: 210,000 iteraciones

Ejemplo de hashing

Como PBKDF2 es el único algoritmo de nuestra lista que cumple con FIPS 140, veremos cómo usarlo para aplicar hashing a contraseñas en Go. Empecemos.

A continuación, se muestra la función ToKey del paquete pbkdf2, que usaremos para generar un hash a partir de una contraseña en texto sin formato:

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

Para realizar el hashing, necesitarás la contraseña y la sal como slices de bytes, junto con el número de iteraciones y la longitud deseada de la clave o el hash generado. También se necesita una función de hashing. En nuestro caso, la contraseña es la contraseña en texto plano que queremos procesar, mientras que la sal será un valor generado aleatoriamente. Puedes configurar las iteraciones y la longitud de la clave. Para la función de hashing, usaremos sha3.New256.

Configuración

// 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
}

Generar el 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
}

Comparar contraseñas

// 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
}

Esto es muy similar a nuestra implementación de Argon2idHash. Por lo tanto, puedes modificar el ejemplo de código completo para usar PBKDF2Hash en su lugar.

Código completo

Ahora, veamos todo en conjunto.

Estructura del proyecto

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

El archivo 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
}

El archivo 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
}

Elige tu algoritmo con cuidado

Los ejemplos anteriores son una excelente introducción a la implementación de algoritmos de hashing seguros para almacenar contraseñas en Go. Recuerda que no todos los algoritmos de hashing son iguales y que debes elegirlos con cuidado según tus necesidades y el estado actual de seguridad del algoritmo. Lo que hoy se considera un algoritmo sólido cambiará con el tiempo, así que conviene reevaluarlo periódicamente.

Además, ten en cuenta que las herramientas SAST como Snyk Code pueden ayudarte a identificar algoritmos obsoletos y recomendarte cómo actualizarlos si es necesario. Elige con cuidado tu algoritmo de hashing de contraseñas, mantente al día y ¡a programar!

Leer más

blog feature ai lilac
Blog

Convierte tu backlog de vulnerabilidades en pull requests con la remediación agéntica

Evo Remediation Agent ya está disponible en Open Preview e incorpora la resolución automatizada de dependencias a Evo Agentic AppSec. Ahora, los equipos de AppSec pueden ejecutar campañas de remediación continuas

AI Background min crop
Blog

La IA activa todos los riesgos que has aceptado: siete ideas del panel de seguridad de IA sin filtros

La IA hace que los ataques sean más baratos y rápidos, y deja al descubierto riesgos que las organizaciones ya enfrentan. Cuatro líderes de seguridad explican por qué la defensa a velocidad de máquina y la corrección más rápida son esenciales.

blog feature snyk container custom base recommendations
Blog

Presentamos Evo Agentic AppSec: seguridad que sigue el ritmo del desarrollo con IA

El desarrollo impulsado por IA avanza más rápido que los flujos de trabajo de seguridad tradicionales. Agentic AppSec de Snyk usa agentes de seguridad para encontrar, corregir y verificar vulnerabilidades de forma continua, mientras las personas establecen el nivel.