Ir al contenido

FundamentosLección 03

Contraseñas, hashing y sal

Por qué una contraseña nunca se cifra ni se guarda con SHA-256, qué hace una sal, por qué el hash debe ser lento y cómo se hace bien en Java.

Básico15 min de lectura

Al terminar sabrás

  • Diferenciar codificar, hashear y cifrar, y saber cuál usar para cada cosa.
  • Explicar qué problema resuelven la sal y un hash lento.
  • Elegir entre Argon2id, bcrypt, scrypt y PBKDF2 con parámetros razonables.
  • Guardar y verificar contraseñas con PasswordEncoder y comparar secretos en tiempo constante.

Tarde o temprano alguien leerá tu tabla de usuarios: una copia de seguridad olvidada, una inyección SQL, un empleado descontento. La pregunta no es si pasará, sino qué podrá hacer con lo que lea. Si guardaste bien las contraseñas, casi nada.

Codificar, hashear, cifrar: tres cosas distintas

Sección titulada «Codificar, hashear, cifrar: tres cosas distintas»

Se confunden constantemente, y cada una sirve para algo diferente:

Codificar

ana:secretoBase64YW5hOnNlY3JldG8=
  • ✓ reversible por cualquiera
  • ✕ necesita clave
  • ✕ protege algo

Cifrar

IBAN del clienteAES-GCM + clave9f3a…c1 (+ tag)
  • ✓ reversible con la clave
  • ✓ necesita clave
  • ✓ para datos que debes recuperar

Hashear

fichero.zipSHA-256e3b0c442…b855
  • ✓ irreversible
  • ✓ rapidísimo (malo para contraseñas)
  • ✓ para integridad

Hash de contraseña

secretobcrypt / Argon2id + sal$2a$10$N9qo8uLO…
  • ✓ irreversible
  • ✓ lento a propósito
  • ✓ sal única por usuario
Solo el hash de contraseñas está diseñado para que adivinar sea caro.
  • Codificar (Base64, URL encoding) solo cambia la representación. No es seguridad: la cabecera Authorization: Basic lleva usuario y contraseña en Base64, y por eso solo es aceptable sobre HTTPS.
  • Cifrar protege datos que necesitarás recuperar (un número de tarjeta, un token de un tercero). Quien tenga la clave puede descifrar.
  • Hashear produce una huella de tamaño fijo que no se puede invertir. Sirve para comprobar integridad y, con un algoritmo especial, para contraseñas.

SHA-256 es un excelente hash… para ficheros. Para contraseñas es pésimo porque es rápido: una sola GPU calcula miles de millones de SHA-256 por segundo. Con eso, un diccionario de contraseñas filtradas se prueba entero en segundos.

Y sin sal, el problema empeora:

  • Dos usuarios con la misma contraseña tienen el mismo hash: si descubres una, descubres las dos.
  • Se pueden usar tablas precalculadas (rainbow tables) que asocian millones de hashes conocidos con su contraseña.

La sal es un valor aleatorio, distinto para cada usuario, que se mezcla con la contraseña antes de hashear y se guarda junto al hash. No es secreta: su trabajo es que cada hash sea único y que nada se pueda precalcular.

Los algoritmos de hash de contraseñas son adaptativos: tienen un parámetro de coste que puedes subir a medida que el hardware mejora.

AlgoritmoPor quéParámetros mínimos (OWASP)
Argon2idUsa mucha memoria: caro para GPUs y hardware especializado. Primera opción.19 MiB de memoria, 2 iteraciones, paralelismo 1
scryptTambién costoso en memoriaN = 2¹⁷, r = 8, p = 1
bcryptProbadísimo y disponible en todas partes. Solo usa los primeros 72 bytes de la contraseña.coste ≥ 10
PBKDF2Cuando se exige cumplimiento FIPS600 000 iteraciones con HMAC-SHA-256

La regla práctica: ajusta el coste para que verificar una contraseña tarde entre 100 y 500 ms en tu servidor. Un usuario no lo nota al iniciar sesión; un atacante que quiere probar mil millones de contraseñas, sí.

Todo lo necesario para verificar viaja dentro del propio hash:

  1. $2a$versión del algoritmo
  2. 10$coste: 2¹⁰ rondas
  3. N9qo8uLOickgx2ZMRZoMyesal (22 caracteres)
  4. IjZAgcfl7p92ldGxad68LJZdL17lhWyhash (31 caracteres)

En Java no hace falta implementar nada: Spring Security incluye las implementaciones en el módulo spring-security-crypto, que puedes usar incluso sin el resto del framework.

Passwords.java
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String hash = encoder.encode("correct horse battery staple");
// {bcrypt}$2a$10$ZK3x8m4p... ← el prefijo dice qué algoritmo se usó
boolean ok = encoder.matches("correct horse battery staple", hash); // true
boolean ko = encoder.matches("Tr0ub4dor&3", hash); // false

El DelegatingPasswordEncoder guarda el algoritmo como prefijo ({bcrypt}, {argon2}…). Gracias a eso puedes cambiar de algoritmo mañana sin invalidar las contraseñas existentes: las viejas se siguen verificando con el algoritmo antiguo y se pueden re-hashear con el nuevo la próxima vez que el usuario inicie sesión.

Evita

// Rápido, sin sal, y se compara con equals
String hash = sha256(password);
if (hash.equals(user.passwordHash())) {
login(user);
}

Prefiere

// Lento, con sal, comparación en tiempo constante
if (encoder.matches(password, user.passwordHash())) {
login(user);
}

Para otros secretos que sí comparas tú (tokens de API, firmas de webhooks), String.equals es peligroso: se detiene en el primer carácter distinto, y midiendo cuánto tarda un atacante puede adivinar el secreto carácter a carácter. Usa una comparación en tiempo constante:

Webhooks.java
static byte[] hmacSha256(byte[] key, byte[] data) throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
}
// ...al recibir el webhook:
byte[] expected = hmacSha256(secret, body);
byte[] received = HexFormat.of().parseHex(signatureHeader);
if (!MessageDigest.isEqual(expected, received)) {
throw new BadCredentialsException("Firma inválida");
}

Las recomendaciones actuales (NIST SP 800-63B) contradicen muchas costumbres antiguas:

  • Longitud antes que complejidad: al menos 15 caracteres si la contraseña es el único factor (8 si hay MFA), y permitir frases largas, espacios y cualquier carácter Unicode.
  • Sin reglas de composición obligatorias (“una mayúscula, un número y un símbolo”): producen Password1!.
  • Comprobar contra contraseñas filtradas (por ejemplo, la API de Have I Been Pwned por rango de hash, que no envía la contraseña).
  • Sin caducidad periódica: cambiar solo cuando hay indicios de compromiso.
  • Limitar los intentos de login y permitir gestores de contraseñas (no bloquees el pegado).

Mini lab · 15 min

Mide el coste de bcrypt

Objetivo: Elegir el coste de bcrypt adecuado para tu máquina y comprobar el papel de la sal.

Con la dependencia org.springframework.security:spring-security-crypto en un proyecto Java cualquiera:

BcryptCost.java
public static void main(String[] args) {
for (int strength = 8; strength <= 14; strength++) {
PasswordEncoder encoder = new BCryptPasswordEncoder(strength);
long start = System.nanoTime();
String hash = encoder.encode("una frase de paso bastante larga");
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.printf("strength=%d %d ms %s%n", strength, ms, hash);
}
}
  1. Ejecútalo. ¿Qué pasa con el tiempo cada vez que subes strength en 1?
  2. ¿Qué valor eliges para que una verificación tarde unos 250 ms?
  3. Ejecuta encode dos veces con la misma contraseña. ¿Los hashes coinciden? Entonces, ¿cómo sabe matches que una contraseña es correcta?
Pista

Fíjate en los caracteres que van justo después de $10$ (o el coste que uses) en cada hash.

Mostrar respuestaOcultar respuesta
  1. El tiempo se duplica con cada punto de strength: el coste es exponencial (2strength rondas). De 10 a 14 el trabajo se multiplica por 16.
  2. Depende del hardware; en un portátil moderno suele estar entre 11 y 13. Elige el valor más alto que mantenga el login por debajo de ~500 ms en tu servidor real, no en tu portátil.
  3. No coinciden, porque cada encode genera una sal aleatoria nueva (los 22 caracteres tras el coste). matches no necesita compararlos: lee la sal y el coste del propio hash guardado, vuelve a hashear la contraseña recibida con esos mismos parámetros y compara el resultado en tiempo constante.