Almacén de contraseñas seguro con PBKDF2 y sal
Registro e inicio de sesión como se hacen bien: contraseñas guardadas con PBKDF2 y una sal aleatoria por usuario, política de contraseñas, comparación en tiempo constante, bloqueo tras tres fallos y el mismo mensaje para usuario o contraseña incorrectos. Criptografía de Java y programación segura.
- PBKDF2WithHmacSHA256
- Sal con SecureRandom
- MessageDigest.isEqual
- Política de contraseñas
- Bloqueo por intentos
- No revelar qué usuarios existen
Enunciado
Guardar contraseñas en texto plano, o con un hash rápido como MD5 o SHA-256 sin más, es uno de los fallos de seguridad más graves y más frecuentes. Si alguien roba la base de datos, con un hash rápido puede probar miles de millones de contraseñas por segundo.
La forma correcta es usar una función de derivación lenta a propósito, como PBKDF2, bcrypt o Argon2, con una sal aleatoria distinta para cada usuario. La sal hace que dos usuarios con la misma contraseña tengan hashes distintos (y que no sirvan las tablas precalculadas), y las miles de iteraciones hacen que cada intento cueste tiempo.
El inicio de sesión también tiene sus reglas: comparar los hashes en tiempo constante, bloquear la cuenta tras varios fallos seguidos y responder lo mismo si el usuario no existe que si la contraseña es incorrecta, para que nadie pueda averiguar qué usuarios hay. Como la sal es aleatoria, el programa nunca escribe hashes: solo el resultado de cada operación.
Qué tiene que hacer el programa
- Cada línea es una orden con sus argumentos separados por espacios (la orden en mayúsculas o minúsculas; las líneas vacías se ignoran). Antes de responder se escribe
> ORDEN usuario:(la orden en mayúsculas y solo el primer argumento, nunca la contraseña) y después la respuesta en la misma línea. Una orden desconocida respondeorden desconocida. REGISTRA usuario contraseña:el usuario ya existe; o, si la contraseña no cumple la política,contraseña rechazada, motivocon el primer motivo:debe tener al menos 10 caracteres,debe tener mayúsculas, minúsculas y números,no puede contener el nombre de usuario(sin distinguir mayúsculas); si no, guarda una sal aleatoria de 16 bytes y el hash PBKDF2WithHmacSHA256 (10.000 iteraciones, 256 bits) y responderegistrado.ENTRA usuario contraseña: si el usuario no existe o la contraseña no es correcta,usuario o contraseña incorrectos; al tercer fallo seguido,usuario o contraseña incorrectos; cuenta bloqueada tras 3 fallos; con la cuenta bloqueada,cuenta bloqueadaaunque la contraseña sea buena; si es correcta,bienvenidoy el contador de fallos vuelve a 0.CAMBIA usuario actual nueva:no se puede cambiar la contraseñasi el usuario no existe, está bloqueado o la actual no es correcta; después, la política (contraseña rechazada, motivo) yla nueva contraseña debe ser distinta; si todo va bien, nueva sal y nuevo hash, ycontraseña cambiada.DESBLOQUEA usuario→desbloqueada(y fallos a 0) ono existe.COMPARA u1 u2→¿mismo hash que u2? sí/noono existe alguno de los usuarios.INFO usuario→PBKDF2WithHmacSHA256, 10000 iteraciones, sal de 16 bytes, hash de 32 bytes(con, bloqueadaal final si lo está) ono existe.- Una orden con un número de argumentos que no es el suyo responde
uso: …con su formato (uso: REGISTRA usuario contraseña,uso: ENTRA usuario contraseña,uso: CAMBIA usuario actual nueva). - Las contraseñas se comparan con
MessageDigest.isEqual, nunca conArrays.equalsni comparando textos.
Entrada
Una orden por línea: REGISTRA, ENTRA, CAMBIA, DESBLOQUEA, COMPARA o INFO.
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Registro, inicio de sesión y bloqueo
Entrada
REGISTRA ana Corta1 REGISTRA ana sinmayusculas123 REGISTRA ana MiAnaClave2026 REGISTRA ana Gatos.Tigres.2026 REGISTRA eva Gatos.Tigres.2026 REGISTRA ana OtraClave99999 COMPARA ana eva ENTRA ana gatos.tigres.2026 ENTRA nadie Gatos.Tigres.2026 ENTRA ana Gatos.Tigres.2026 ENTRA eva mal ENTRA eva mal ENTRA eva mal ENTRA eva Gatos.Tigres.2026 INFO eva
Salida por consola
> REGISTRA ana: contraseña rechazada, debe tener al menos 10 caracteres > REGISTRA ana: contraseña rechazada, debe tener mayúsculas, minúsculas y números > REGISTRA ana: contraseña rechazada, no puede contener el nombre de usuario > REGISTRA ana: registrado > REGISTRA eva: registrado > REGISTRA ana: el usuario ya existe > COMPARA ana: ¿mismo hash que eva? no > ENTRA ana: usuario o contraseña incorrectos > ENTRA nadie: usuario o contraseña incorrectos > ENTRA ana: bienvenido > ENTRA eva: usuario o contraseña incorrectos > ENTRA eva: usuario o contraseña incorrectos > ENTRA eva: usuario o contraseña incorrectos; cuenta bloqueada tras 3 fallos > ENTRA eva: cuenta bloqueada > INFO eva: PBKDF2WithHmacSHA256, 10000 iteraciones, sal de 16 bytes, hash de 32 bytes, bloqueada
Cambio de contraseña y desbloqueo
Entrada
REGISTRA luis Montañas.2026 CAMBIA luis Equivocada1 Nueva.Clave.27 CAMBIA luis Montañas.2026 Montañas.2026 CAMBIA luis Montañas.2026 corta CAMBIA luis Montañas.2026 Nueva.Clave.27 ENTRA luis Montañas.2026 ENTRA luis Nueva.Clave.27 DESBLOQUEA luis INFO luis
Salida por consola
> REGISTRA luis: registrado > CAMBIA luis: no se puede cambiar la contraseña > CAMBIA luis: la nueva contraseña debe ser distinta > CAMBIA luis: contraseña rechazada, debe tener al menos 10 caracteres > CAMBIA luis: contraseña cambiada > ENTRA luis: usuario o contraseña incorrectos > ENTRA luis: bienvenido > DESBLOQUEA luis: desbloqueada > INFO luis: PBKDF2WithHmacSHA256, 10000 iteraciones, sal de 16 bytes, hash de 32 bytes
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Derivar la clave
PBEKeySpec reúne la contraseña, la sal, las iteraciones y la longitud en bits; SecretKeyFactory con el algoritmo PBKDF2WithHmacSHA256 hace el cálculo. El método derivar ya lo tienes en el código inicial.
2. Una sal por contraseña
Al registrar (y al cambiar), genera 16 bytes con SecureRandom.nextBytes y guárdalos junto al hash. Para comprobar una contraseña se vuelve a derivar con la sal guardada y se comparan los hashes.
cuenta.sal = new byte[16];
azar.nextBytes(cuenta.sal);
cuenta.hash = derivar(contrasena, cuenta.sal);3. Comparar en tiempo constante
Una comparación normal se detiene en el primer byte distinto, y midiendo el tiempo de respuesta un atacante podría ir averiguando el hash. MessageDigest.isEqual(a, b) compara siempre todos los bytes.
4. La política, en un método
Escribe politica(usuario, contraseña) que devuelva null o el primer motivo de rechazo, y úsala en el registro y en el cambio. Con matches(".*[A-Z].*") compruebas si hay alguna mayúscula.
5. Mensajes que no dan pistas
Al iniciar sesión, un usuario inexistente y una contraseña incorrecta responden igual. Si respondieras «el usuario no existe», cualquiera podría probar nombres hasta encontrar cuentas reales.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
REGISTRA ana Corta1 REGISTRA ana sinmayusculas123 REGISTRA ana MiAnaClave2026 REGISTRA ana Gatos.Tigres.2026 REGISTRA eva Gatos.Tigres.2026 REGISTRA ana OtraClave99999 COMPARA ana eva ENTRA ana gatos.tigres.2026 ENTRA nadie Gatos.Tigres.2026 ENTRA ana Gatos.Tigres.2026 ENTRA eva mal ENTRA eva mal ENTRA eva mal ENTRA eva Gatos.Tigres.2026 INFO eva
> REGISTRA ana: contraseña rechazada, debe tener al menos 10 caracteres > REGISTRA ana: contraseña rechazada, debe tener mayúsculas, minúsculas y números > REGISTRA ana: contraseña rechazada, no puede contener el nombre de usuario > REGISTRA ana: registrado > REGISTRA eva: registrado > REGISTRA ana: el usuario ya existe > COMPARA ana: ¿mismo hash que eva? no > ENTRA ana: usuario o contraseña incorrectos > ENTRA nadie: usuario o contraseña incorrectos > ENTRA ana: bienvenido > ENTRA eva: usuario o contraseña incorrectos > ENTRA eva: usuario o contraseña incorrectos > ENTRA eva: usuario o contraseña incorrectos; cuenta bloqueada tras 3 fallos > ENTRA eva: cuenta bloqueada > INFO eva: PBKDF2WithHmacSHA256, 10000 iteraciones, sal de 16 bytes, hash de 32 bytes, bloqueada
Solución explicada
Ver la solución completa
1import java.security.MessageDigest;
2import java.security.SecureRandom;
3import java.util.HashMap;
4import java.util.Map;
5import java.util.Scanner;
6import javax.crypto.SecretKeyFactory;
7import javax.crypto.spec.PBEKeySpec;
8
9public class Main {
10 static final String ALGORITMO = "PBKDF2WithHmacSHA256";
11 static final int ITERACIONES = 10_000; // en producción, cientos de miles: aquí menos para que las pruebas vayan rápido
12 static final int MAX_FALLOS = 3;
13
14 static class Cuenta {
15 byte[] sal;
16 byte[] hash;
17 int fallos;
18 boolean bloqueada;
19 }
20
21 static final Map<String, Cuenta> cuentas = new HashMap<>();
22 static final SecureRandom azar = new SecureRandom();
23
24 static byte[] derivar(String contrasena, byte[] sal) throws Exception {
25 PBEKeySpec spec = new PBEKeySpec(contrasena.toCharArray(), sal, ITERACIONES, 256);
26 return SecretKeyFactory.getInstance(ALGORITMO).generateSecret(spec).getEncoded();
27 }
28
29 /** null si la contraseña cumple la política, o el primer motivo por el que no. */
30 static String politica(String usuario, String c) {
31 if (c.length() < 10) return "debe tener al menos 10 caracteres";
32 if (!c.matches(".*[A-Z].*") || !c.matches(".*[a-z].*") || !c.matches(".*\\d.*")) return "debe tener mayúsculas, minúsculas y números";
33 if (c.toLowerCase().contains(usuario.toLowerCase())) return "no puede contener el nombre de usuario";
34 return null;
35 }
36
37 static Cuenta nueva(String contrasena) throws Exception {
38 Cuenta cuenta = new Cuenta();
39 cuenta.sal = new byte[16];
40 azar.nextBytes(cuenta.sal); // una sal distinta para cada contraseña
41 cuenta.hash = derivar(contrasena, cuenta.sal);
42 return cuenta;
43 }
44
45 /** Comprueba la contraseña en tiempo constante (MessageDigest.isEqual no corta en el primer byte distinto). */
46 static boolean correcta(Cuenta cuenta, String contrasena) throws Exception {
47 return MessageDigest.isEqual(cuenta.hash, derivar(contrasena, cuenta.sal));
48 }
49
50 public static void main(String[] args) throws Exception {
51 Scanner sc = new Scanner(System.in);
52 while (sc.hasNextLine()) {
53 String linea = sc.nextLine().trim();
54 if (linea.isEmpty()) continue;
55 String[] p = linea.split("\\s+");
56 String orden = p[0].toUpperCase();
57 System.out.print("> " + orden + (p.length > 1 ? " " + p[1] : "") + ": ");
58 switch (orden) {
59 case "REGISTRA" -> {
60 if (p.length != 3) { System.out.println("uso: REGISTRA usuario contraseña"); break; }
61 if (cuentas.containsKey(p[1])) { System.out.println("el usuario ya existe"); break; }
62 String motivo = politica(p[1], p[2]);
63 if (motivo != null) { System.out.println("contraseña rechazada, " + motivo); break; }
64 cuentas.put(p[1], nueva(p[2]));
65 System.out.println("registrado");
66 }
67 case "ENTRA" -> {
68 if (p.length != 3) { System.out.println("uso: ENTRA usuario contraseña"); break; }
69 Cuenta cuenta = cuentas.get(p[1]);
70 // El mismo mensaje si el usuario no existe: así no se puede averiguar qué usuarios hay
71 if (cuenta == null) { System.out.println("usuario o contraseña incorrectos"); break; }
72 if (cuenta.bloqueada) { System.out.println("cuenta bloqueada"); break; }
73 if (correcta(cuenta, p[2])) {
74 cuenta.fallos = 0;
75 System.out.println("bienvenido");
76 } else if (++cuenta.fallos >= MAX_FALLOS) {
77 cuenta.bloqueada = true;
78 System.out.println("usuario o contraseña incorrectos; cuenta bloqueada tras " + MAX_FALLOS + " fallos");
79 } else {
80 System.out.println("usuario o contraseña incorrectos");
81 }
82 }
83 case "CAMBIA" -> {
84 if (p.length != 4) { System.out.println("uso: CAMBIA usuario actual nueva"); break; }
85 Cuenta cuenta = cuentas.get(p[1]);
86 if (cuenta == null || cuenta.bloqueada || !correcta(cuenta, p[2])) { System.out.println("no se puede cambiar la contraseña"); break; }
87 String motivo = politica(p[1], p[3]);
88 if (motivo != null) { System.out.println("contraseña rechazada, " + motivo); break; }
89 if (p[3].equals(p[2])) { System.out.println("la nueva contraseña debe ser distinta"); break; }
90 cuentas.put(p[1], nueva(p[3]));
91 System.out.println("contraseña cambiada");
92 }
93 case "DESBLOQUEA" -> {
94 Cuenta cuenta = p.length == 2 ? cuentas.get(p[1]) : null;
95 if (cuenta == null) { System.out.println("no existe"); break; }
96 cuenta.bloqueada = false;
97 cuenta.fallos = 0;
98 System.out.println("desbloqueada");
99 }
100 case "COMPARA" -> {
101 Cuenta a = p.length == 3 ? cuentas.get(p[1]) : null, b = p.length == 3 ? cuentas.get(p[2]) : null;
102 if (a == null || b == null) { System.out.println("no existe alguno de los usuarios"); break; }
103 System.out.println("¿mismo hash que " + p[2] + "? " + (MessageDigest.isEqual(a.hash, b.hash) ? "sí" : "no"));
104 }
105 case "INFO" -> {
106 Cuenta cuenta = p.length == 2 ? cuentas.get(p[1]) : null;
107 if (cuenta == null) { System.out.println("no existe"); break; }
108 System.out.println(ALGORITMO + ", " + ITERACIONES + " iteraciones, sal de " + cuenta.sal.length
109 + " bytes, hash de " + cuenta.hash.length + " bytes" + (cuenta.bloqueada ? ", bloqueada" : ""));
110 }
111 default -> System.out.println("orden desconocida");
112 }
113 }
114 }
115}La contraseña nunca se guarda: se guardan la sal y el resultado de PBKDF2. Para comprobar un intento se repite la derivación con la misma sal; si coincide, la contraseña era la correcta. Ni siquiera el administrador puede recuperar una contraseña, solo cambiarla.
La sal aleatoria explica el resultado de COMPARA: aunque dos usuarios elijan la misma contraseña, sus hashes son distintos. Sin sal, bastaría ver dos hashes iguales en la base de datos robada para saber que comparten contraseña.
Las iteraciones son un parámetro de coste: cada una multiplica el trabajo de quien intente adivinar contraseñas por fuerza bruta. Aquí son 10.000 para que las pruebas vayan rápidas; las guías actuales recomiendan cientos de miles para PBKDF2, o usar Argon2.
El bloqueo tras tres fallos y los mensajes que no distinguen entre usuario y contraseña cierran las otras dos vías de ataque: probar contraseñas contra una cuenta y averiguar qué cuentas existen.
Para ir más allá
- Guarda cada cuenta como una cadena
pbkdf2$iteraciones$sal$hashen Base64 y permite subir las iteraciones sin invalidar las contraseñas antiguas. - Sustituye el bloqueo permanente por uno temporal (15 minutos) con
Instant. - Compara el tiempo de PBKDF2 con 10.000 y con 600.000 iteraciones, y con un SHA-256 simple.