Ir al contenido
wannacode

Contraseñas

En resumen: Las contraseñas se guardan como un hash lento con sal (bcrypt o argon2id), no cifradas ni con SHA-256 rápido, para que una filtración de la base de datos no entregue las contraseñas en sí.

Problema

La copia de seguridad que se hizo pública

La tienda guarda a sus compradores en una tabla users: un correo y una contraseña. Alguien sube una copia de seguridad de la base de datos a una carpeta pública, y una semana después está en internet. Lo que pierden los compradores depende solo de cómo se guardó la contraseña:

  • como texto: todas las contraseñas están a la vista. Mucha gente usa la misma contraseña en todas partes, así que también se filtran sus cuentas de correo;
  • cifrada con una clave que está junto a la base de datos: lo mismo. La clave está en la configuración del mismo servidor y se filtra con él;
  • como SHA-256 sin sal: las contraseñas no se ven, pero contraseñas iguales dan hashes iguales. La propia tabla muestra quién comparte contraseña con quién. Y quien tiene la tabla puede tomar una lista de contraseñas comunes, hashear cada una y buscar coincidencias: es un ataque de diccionario, y SHA-256 se hizo para ser rápido.

Una filtración nunca se puede descartar del todo. Por eso la contraseña se guarda de modo que una filtración no entregue las contraseñas.

La idea

Guarda la huella, no la contraseña

El servidor no necesita conocer la contraseña. Solo tiene que comprobar si la contraseña escrita es la misma. Para eso sirve un hash criptográfico: una función que convierte texto en una huella de longitud fija y no se puede revertir. La misma entrada da la misma huella, y la huella no lleva de vuelta a la entrada.

Este no es el hashCode de la lección sobre HashMap: aquel es rápido, corto y colisiona a menudo, porque está hecho para cubetas. Un hash criptográfico es otra cosa.

El código viejo de la tienda guarda el SHA-256 de la contraseña:

record Row(String email, String salt, String hash) {}
static String sha256(String text) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(text.getBytes(UTF_8));
return HexFormat.of().formatHex(hash);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
// El código viejo de la tienda: SHA-256 sin sal.
static Row register(String email, String password) {
return new Row(email, "", sha256(password));
}

Cuatro compradores; ana y vera tienen la misma contraseña. La función sharedRows cuenta las filas cuyo hash aparece más de una vez:

static long sharedRows(List<Row> users) {
Map<String, Long> perHash = users.stream().collect(
Collectors.groupingBy(Row::hash, Collectors.counting()));
return users.stream()
.filter(row -> perHash.get(row.hash()) > 1)
.count();
}
List<Row> users = List.of(
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"));
System.out.println("La tabla users:");
for (Row row : users)
System.out.printf("%-20s%s…%n", row.email(), row.hash().substring(0, 16));
System.out.println("Filas con un hash en común: " + sharedRows(users));
Salida
La tabla users:
ana@example.com 65e84be33532fb78…
boris@example.com 6191711a1871a6bd…
vera@example.com 65e84be33532fb78…
georgi@example.com 9cbd9835924cf733…
Filas con un hash en común: 2

Las filas de ana y vera son iguales. Sin romper nada, cualquiera con la tabla ve que las dos tienen una sola contraseña. Si una la revela, revela también la de la otra.

La idea

Una sal: cada fila distinta

La solución para las filas iguales es una sal: bytes aleatorios, distintos para cada fila, que se ponen junto a la contraseña antes del hash. La sal no es un secreto. Está a la vista en la fila, porque el inicio de sesión la necesita otra vez.

static final SecureRandom RANDOM = new SecureRandom();
// Una sal aleatoria propia para cada fila.
static Row register(String email, String password) {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
String salt = HexFormat.of().formatHex(bytes);
return new Row(email, salt, sha256(salt + password));
}

SecureRandom es un generador criptográfico de números aleatorios: su siguiente valor no se puede predecir. (Por qué no sirve el generador normal se explica en la lección sobre criptografía.)

La sal es distinta en cada ejecución, así que el programa imprime solo lo que no cambia:

List<Row> users = List.of(
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"));
boolean same = users.get(0).hash().equals(users.get(2).hash());
System.out.println("Mismo registro para ana y vera: " + same);
System.out.println("Filas con un hash en común: " + sharedRows(users));
Salida
Mismo registro para ana y vera: false
Filas con un hash en común: 0

Las contraseñas iguales ya no se notan, y un cálculo vale solo para una fila. Pero SHA-256 sigue siendo rápido: una tarjeta gráfica calcula miles de millones por segundo, así que el ataque de diccionario sigue funcionando, solo que fila por fila. El hash además tiene que ser lento a propósito.

Míralo

La tabla users: sin sal, con sal, con hash lento

// demo; en un servidor real 600_000 (OWASP)
static final int ITERATIONS = 10_000;
static final SecureRandom RANDOM = new SecureRandom();
static String hashPassword(String password) {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
byte[] hash = pbkdf2(password, salt, ITERATIONS);
var b64 = Base64.getEncoder();
return "pbkdf2_sha256$" + ITERATIONS + "$"
+ b64.encodeToString(salt) + "$"
+ b64.encodeToString(hash);
}
static boolean verify(String password, String record) {
String[] parts = record.split("\\$");
int iterations = Integer.parseInt(parts[1]);
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] expected = Base64.getDecoder().decode(parts[3]);
byte[] actual = pbkdf2(password, salt, iterations);
return MessageDigest.isEqual(expected, actual);
}
Paso a paso, en palabras
  1. La tabla users con el código viejo de la tienda: un correo y el SHA-256 de la contraseña, sin sal.
  2. Contraseñas iguales dan hashes iguales. ana y vera tienen el mismo valor, así que la propia tabla muestra quién comparte contraseña: 2 filas.
  3. Ahora cada fila recibe una sal aleatoria propia, y la fila guarda SHA-256(sal + contraseña).
  4. Cada fila es distinta, también ana y vera: 0 filas con un hash en común. Pero SHA-256 es rápido, así que cada intento sobre una fila sigue siendo barato.
  5. La solución es un hash lento: PBKDF2 repite HMAC-SHA256 10 000 veces aquí (una demo). En un servidor real son 600 000.
  6. El registro guarda todo lo necesario para comprobar después: algoritmo $ coste $ sal $ hash. La contraseña en sí no está en él.
  7. Al iniciar sesión el código lee el coste y la sal del registro y hashea la contraseña escrita de la misma manera.
  8. Los dos hashes se comparan en tiempo constante: "qwerty" da ✓, "qwerty1" da ✗. Las contraseñas iguales ya no se notan, y cada intento es lento.

El último cuadro junta los tres pasos: la columna guarda un registro con sal y coste, no hay dos filas iguales, y el inicio de sesión comprueba la contraseña contra el registro.

La idea

Un hash lento y el registro

Un hash lento de contraseñas repite su trabajo muchas veces. Cuántas veces es el coste (work factor). Un inicio de sesión lo calcula una vez y espera una fracción de segundo. Quien tiene la tabla filtrada paga el mismo precio por cada intento.

La biblioteca estándar tiene PBKDF2: HMAC-SHA256, repetido tantas iteraciones como indiques.
static byte[] pbkdf2(String password, byte[] salt,
int iterations) {
try {
var spec = new PBEKeySpec(password.toCharArray(),
salt, iterations, 256);
return SecretKeyFactory
.getInstance("PBKDF2WithHmacSHA256")
.generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
PBEKeySpec toma la contraseña como char[], la sal, las iteraciones y la longitud en bits (256). SecretKeyFactory con el nombre PBKDF2WithHmacSHA256 hace el trabajo de verdad.

El registro guarda todo lo que necesita la comprobación: algoritmo, coste, sal y hash, con $ entre ellos. Así el coste puede subir más tarde sin romper los registros viejos.

// demo; en un servidor real 600_000 (OWASP)
static final int ITERATIONS = 10_000;
static final SecureRandom RANDOM = new SecureRandom();
static String hashPassword(String password) {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
byte[] hash = pbkdf2(password, salt, ITERATIONS);
var b64 = Base64.getEncoder();
return "pbkdf2_sha256$" + ITERATIONS + "$"
+ b64.encodeToString(salt) + "$"
+ b64.encodeToString(hash);
}
static boolean verify(String password, String record) {
String[] parts = record.split("\\$");
int iterations = Integer.parseInt(parts[1]);
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] expected = Base64.getDecoder().decode(parts[3]);
byte[] actual = pbkdf2(password, salt, iterations);
return MessageDigest.isEqual(expected, actual);
}
String record = hashPassword("qwerty");
String[] parts = record.split("\\$");
System.out.println("Algoritmo y coste: " + parts[0] + ", " + parts[1]);
System.out.println("Inicio de sesión con la contraseña correcta: " + verify("qwerty", record));
System.out.println("Inicio de sesión con una contraseña incorrecta: " + verify("qwerty1", record));
System.out.println("Una contraseña, dos registros, iguales: " + record.equals(hashPassword("qwerty")));
Salida
Algoritmo y coste: pbkdf2_sha256, 10000
Inicio de sesión con la contraseña correcta: true
Inicio de sesión con una contraseña incorrecta: false
Una contraseña, dos registros, iguales: false

El coste del programa es pequeño, para que corra rápido. En un servidor real OWASP recomienda 600 000 iteraciones para PBKDF2-HMAC-SHA256. En un proyecto real normalmente no lo escribes tú, sino que usas bcrypt o argon2id de una biblioteca probada (los detalles, en «Más precisamente, para curiosos»).

Pruébalo

Predice la salida

Una misma contraseña, hasheada dos veces. ¿Qué imprimirá?

String first = hashPassword("qwerty");
String second = hashPassword("qwerty");
System.out.println(first.equals(second));
System.out.println(verify("qwerty", first));
System.out.println(verify("qwerty", second));
Respuesta

Pruébalo

Completa

Al iniciar sesión se comparan los dos hashes. Una comparación normal se detiene en el primer byte distinto, y el tiempo de respuesta puede revelar hasta dónde coinciden. ¿Qué comparación recorre siempre todos los bytes?

byte[] actual = pbkdf2(password, salt, iterations);
return MessageDigest.(expected, actual);

Pruébalo

Encuentra el error

Un compañero «simplifica» el código: en lugar de una sal aleatoria para cada fila, una sola sal para todas. El hash lento es el mismo. ¿Dónde está el problema?

Elige la línea con el error.

Míralo

El coste crece con los años

Las computadoras se vuelven más rápidas, así que un coste que bastaba el año pasado no basta pasado un tiempo. Los registros viejos no se pueden recalcular: un hash no va hacia atrás, y el servidor tiene la contraseña solo en el momento de un inicio de sesión. Por eso el hash nuevo se hace en un inicio de sesión correcto. Aquí hashPassword toma el coste como parámetro, y cada registro guarda el suyo, así los registros viejos siguen funcionando.

// el año pasado era 10_000
static final int ITERATIONS = 20_000;
static int costOf(String record) {
return Integer.parseInt(record.split("\\$")[1]);
}
static boolean login(Account account, String password) {
if (!verify(password, account.record)) return false;
if (costOf(account.record) < ITERATIONS) {
// solo ahora tenemos la contraseña en la mano: hash con el coste nuevo
account.record = hashPassword(password, ITERATIONS);
}
return true;
}
var account = new Account(hashPassword("qwerty", 10_000));
System.out.println("Coste antes del inicio de sesión: " + costOf(account.record));
System.out.println("Inicio de sesión: " + login(account, "qwerty"));
System.out.println("Coste después del inicio de sesión: " + costOf(account.record));
Salida
Coste antes del inicio de sesión: 10000
Inicio de sesión: true
Coste después del inicio de sesión: 20000
Más precisamente, para curiosos

Qué parámetros (OWASP, Password Storage Cheat Sheet):

  • argon2id: 19 MiB de memoria, 2 iteraciones, paralelismo 1. La primera opción para un proyecto nuevo;
  • scrypt: N = 2¹⁷, r = 8, p = 1. Necesita 128 MiB de memoria, y tanto en Python como en Node el límite por defecto es de 32 MiB, así que se sube con maxmem;
  • bcrypt: coste 10 o más. Usa solo los primeros 72 bytes de la contraseña;
  • PBKDF2-HMAC-SHA256: 600 000 iteraciones, cuando se exige un algoritmo aprobado por FIPS.

Qué usan los proyectos reales. Rara vez lo escribes tú; usas la biblioteca del framework: Spring Security tiene Argon2PasswordEncoder, BCryptPasswordEncoder y DelegatingPasswordEncoder, que escribe el algoritmo en el registro ({bcrypt}$2a$10$…) y te deja cambiar más adelante.

Pepper. Algunos añaden además un valor secreto, común a todas las filas, que vive fuera de la base de datos (en un almacén de secretos, mira la lección sobre secretos). Una filtración solo de la base de datos entonces no basta. Ayuda, pero no sustituye a la sal ni al hash lento.

La política de contraseñas. Una contraseña larga (al menos 12 caracteres) y una comprobación contra listas de contraseñas filtradas ayudan más que reglas como «una mayúscula y un dígito». Esa es también la tarea de abajo.

Cómo es en los otros lenguajes: Java y C# tienen PBKDF2 en la biblioteca estándar pero no scrypt; Python y Node tienen los dos (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). Ninguno de los cuatro trae argon2id ni bcrypt incorporados.

Ejercicio

Cambiar la contraseña

Resuélvelo en tu editor.

La cuenta del comprador tiene la contraseña "qwerty". Escribe changePassword(account, oldPassword, newPassword), que:

  1. comprueba la contraseña actual con verify y rechaza si es incorrecta;
  2. rechaza una contraseña nueva de menos de 12 caracteres;
  3. rechaza una contraseña nueva que (sin distinguir mayúsculas y minúsculas) esté en la lista password1234, qwerty123456, 123456789012;
  4. si no, guarda un registro nuevo con una sal nueva y devuelve «Contraseña cambiada».

Llámala con ("qwerty1", "new-pass-2026!"), ("qwerty", "short1"), ("qwerty", "Qwerty123456") y ("qwerty", "tram-9-to-vitosha"), y después comprueba el inicio de sesión con la contraseña nueva:

Salida
Contraseña actual incorrecta
La contraseña nueva tiene menos de 12 caracteres
Esta contraseña está en las listas de contraseñas filtradas
Contraseña cambiada
Inicio de sesión con la contraseña nueva: true

Más tareas

  • Un hash nuevo al iniciar sesión. Añade login de «El coste crece con los años» y comprueba que una cuenta vieja con un coste menor sale del inicio de sesión con el nuevo.
  • El mismo tiempo para un correo incorrecto. Si el correo no existe, el inicio de sesión responde de inmediato; si existe, calcula el hash lento. ¿Cómo harías que los dos casos tarden lo mismo?

Test

Ponte a prueba

1. ¿Por qué las contraseñas no se guardan cifradas con una clave?
2. ¿Qué hace una sal, y qué NO hace?
3. ¿Por qué un hash lento ayuda en lugar de perjudicar?
4. ¿Por qué se compara el hash en tiempo constante al iniciar sesión?
5. Subes el coste de 10 000 a 20 000 iteraciones. ¿Qué pasa con los registros viejos?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·