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 hash de la lección sobre Map y Set: 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:

const sha256 = (text) =>
createHash("sha256").update(text).digest("hex");
// El código viejo de la tienda: SHA-256 sin sal.
function register(email, password) {
return { email, salt: "", hash: 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:

function sharedRows(users) {
const perHash = new Map();
for (const { hash } of users) {
perHash.set(hash, (perHash.get(hash) ?? 0) + 1);
}
return users.filter((row) => perHash.get(row.hash) > 1).length;
}
const users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
console.log("La tabla users:");
for (const row of users) {
console.log(row.email.padEnd(20) + row.hash.slice(0, 16) + "…");
}
console.log("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.

// Una sal aleatoria propia para cada fila.
function register(email, password) {
const salt = randomBytes(16).toString("hex");
return { email, salt, hash: sha256(salt + password) };
}

randomBytes de node:crypto 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:

const users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
console.log("Mismo registro para ana y vera:", users[0].hash === users[2].hash);
console.log("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 2 ** 17 (OWASP)
const N = 2 ** 12;
function hashPassword(password) {
const salt = randomBytes(16);
const hash = kdf(password, salt, N);
return ["scrypt", N, salt.toString("base64"),
hash.toString("base64")].join("$");
}
function verify(password, record) {
const [, n, salt, expected] = record.split("$");
const actual = kdf(password, Buffer.from(salt, "base64"),
Number(n));
const want = Buffer.from(expected, "base64");
return timingSafeEqual(actual, want);
}
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: scrypt con coste N = 4096 aquí (una demo), que además necesita memoria. En un servidor real N = 2^17.
  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 scrypt: además de tiempo necesita memoria, y la memoria es cara para las tarjetas gráficas.
const kdf = (password, salt, n) =>
scryptSync(password, salt, 32, { N: n });
scryptSync toma la contraseña, la sal, la longitud en bytes y N (el coste, una potencia de dos). En un servidor real usa el crypto.scrypt asíncrono: el síncrono bloquea el bucle de eventos durante todo el cálculo (mira la lección sobre async).

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 2 ** 17 (OWASP)
const N = 2 ** 12;
function hashPassword(password) {
const salt = randomBytes(16);
const hash = kdf(password, salt, N);
return ["scrypt", N, salt.toString("base64"),
hash.toString("base64")].join("$");
}
function verify(password, record) {
const [, n, salt, expected] = record.split("$");
const actual = kdf(password, Buffer.from(salt, "base64"),
Number(n));
const want = Buffer.from(expected, "base64");
return timingSafeEqual(actual, want);
}
const record = hashPassword("qwerty");
const parts = record.split("$");
console.log(`Algoritmo y coste: ${parts[0]}, ${parts[1]}`);
console.log("Inicio de sesión con la contraseña correcta:", verify("qwerty", record));
console.log("Inicio de sesión con una contraseña incorrecta:", verify("qwerty1", record));
console.log("Una contraseña, dos registros, iguales:", record === hashPassword("qwerty"));
Salida
Algoritmo y coste: scrypt, 4096
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 N = 2¹⁷ (r = 8, p = 1) para scrypt. 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á?

const first = hashPassword("qwerty");
const second = hashPassword("qwerty");
console.log(first === second);
console.log(verify("qwerty", first));
console.log(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?

const want = Buffer.from(expected, "base64");
return (actual, want);

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 2 ** 11
const N = 2 ** 12;
const costOf = (record) => Number(record.split("$")[1]);
function login(account, password) {
if (!verify(password, account.record)) return false;
if (costOf(account.record) < N) {
// solo ahora tenemos la contraseña en la mano: hash con el coste nuevo
account.record = hashPassword(password, N);
}
return true;
}
const account = { record: hashPassword("qwerty", 2 ** 11) };
console.log("Coste antes del inicio de sesión:", costOf(account.record));
console.log("Inicio de sesión:", login(account, "qwerty"));
console.log("Coste después del inicio de sesión:", costOf(account.record));
Salida
Coste antes del inicio de sesión: 2048
Inicio de sesión: true
Coste después del inicio de sesión: 4096
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: Los paquetes de npm argon2 y bcrypt (Node no trae ninguno incorporado). Dan hash y verify asíncronos y escriben el algoritmo y el coste en el registro.

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 ·