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.
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
PasswordEncodery 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
- Codificar (Base64, URL encoding) solo cambia la representación. No es seguridad: la cabecera
Authorization: Basiclleva 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.
Por qué un hash normal no sirve
Sección titulada «Por qué un hash normal no sirve»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 correctos
Sección titulada «Los algoritmos correctos»Los algoritmos de hash de contraseñas son adaptativos: tienen un parámetro de coste que puedes subir a medida que el hardware mejora.
| Algoritmo | Por qué | Parámetros mínimos (OWASP) |
|---|---|---|
| Argon2id | Usa mucha memoria: caro para GPUs y hardware especializado. Primera opción. | 19 MiB de memoria, 2 iteraciones, paralelismo 1 |
| scrypt | También costoso en memoria | N = 2¹⁷, r = 8, p = 1 |
| bcrypt | Probadísimo y disponible en todas partes. Solo usa los primeros 72 bytes de la contraseña. | coste ≥ 10 |
| PBKDF2 | Cuando se exige cumplimiento FIPS | 600 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í.
Anatomía de un hash bcrypt
Sección titulada «Anatomía de un hash bcrypt»Todo lo necesario para verificar viaja dentro del propio hash:
$2a$versión del algoritmo10$coste: 2¹⁰ rondasN9qo8uLOickgx2ZMRZoMyesal (22 caracteres)IjZAgcfl7p92ldGxad68LJZdL17lhWyhash (31 caracteres)
En código
Sección titulada «En código»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.
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); // trueboolean ko = encoder.matches("Tr0ub4dor&3", hash); // falseEl 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 equalsString hash = sha256(password);if (hash.equals(user.passwordHash())) { login(user);}Prefiere
// Lento, con sal, comparación en tiempo constanteif (encoder.matches(password, user.passwordHash())) { login(user);}Comparar secretos sin filtrar información
Sección titulada «Comparar secretos sin filtrar información»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:
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");}Políticas de contraseña modernas
Sección titulada «Políticas de contraseña modernas»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:
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); }}- Ejecútalo. ¿Qué pasa con el tiempo cada vez que subes
strengthen 1? - ¿Qué valor eliges para que una verificación tarde unos 250 ms?
- Ejecuta
encodedos veces con la misma contraseña. ¿Los hashes coinciden? Entonces, ¿cómo sabematchesque 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
- 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. - 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.
- No coinciden, porque cada
encodegenera una sal aleatoria nueva (los 22 caracteres tras el coste).matchesno 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.