Apuntes DAM
Volver al inicio

Almacén de contraseñas seguro con PBKDF2 y sal

Ejercicio de JavaDifícilUnos 70 minutos

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

  1. 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 responde orden desconocida.
  2. REGISTRA usuario contraseña: el usuario ya existe; o, si la contraseña no cumple la política, contraseña rechazada, motivo con 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 responde registrado.
  3. 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 bloqueada aunque la contraseña sea buena; si es correcta, bienvenido y el contador de fallos vuelve a 0.
  4. CAMBIA usuario actual nueva: no se puede cambiar la contraseña si el usuario no existe, está bloqueado o la actual no es correcta; después, la política (contraseña rechazada, motivo) y la nueva contraseña debe ser distinta; si todo va bien, nueva sal y nuevo hash, y contraseña cambiada.
  5. DESBLOQUEA usuario → desbloqueada (y fallos a 0) o no existe. COMPARA u1 u2 → ¿mismo hash que u2? sí/no o no existe alguno de los usuarios. INFO usuario → PBKDF2WithHmacSHA256, 10000 iteraciones, sal de 16 bytes, hash de 32 bytes (con , bloqueada al final si lo está) o no existe.
  6. 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).
  7. Las contraseñas se comparan con MessageDigest.isEqual, nunca con Arrays.equals ni 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.

java
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.

☕JavaAlmacén de contraseñas seguro con PBKDF2 y salDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
> 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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
java
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$hash en 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.

Dónde se explica