Criptografía que no escribes tú
En resumen: La criptografía sale de la biblioteca estándar, no la escribes tú: números aleatorios criptográficamente seguros, comparación en tiempo constante y cifrado autenticado como AES-GCM.
Problema
El código que alguien ya conoce
La tienda envía un código de 6 cifras para «olvidé mi contraseña». El mismo generador hace también los códigos de descuento del boletín. El código parece aleatorio: 807592, 213904, 566120.
Una noche alguien cambia la contraseña de un comprador y compra con su tarjeta. No adivinó un millón de códigos: probó uno. Conocía el código antes de que el comprador abriera el correo.
El generador es mulberry32(started), y started es el segundo en que arrancó la tienda.
La idea
Random es una fórmula, no aleatoriedad
Random es un generador de números pseudoaleatorios: una fórmula que, a partir de un número (la semilla, o seed), calcula una secuencia que parece aleatoria. La misma semilla da la misma secuencia. Para juegos, simulaciones y pruebas eso es útil. Para secretos es un desastre: quien conoce la semilla, o puede adivinarla, conoce cada número siguiente.
La semilla de la tienda es el segundo en que arrancó. El atacante sabe que la tienda se reinició por la noche: 3600 posibilidades. Tiene su propio código de descuento del boletín. Prueba cada semilla hasta que el primer código coincide con el suyo. Entonces el siguiente código de su copia es el código del comprador.
Para todo lo que tiene que ser secreto e impredecible (tokens, códigos, claves, sales, nonces) usas un generador criptográfico. Toma la aleatoriedad del sistema operativo (eventos del hardware, ruido) y está hecho para que su siguiente salida no se pueda calcular a partir de lo anterior.
En JavaScriptMath.random() no tiene semilla, así que la tienda aquí usa mulberry32: un generador pequeño con semilla, de los que se copian en muchos proyectos. Math.random() tampoco es criptográfico: su estado se puede recuperar a partir de unas pocas salidas. Una demo con él no sería determinista, por eso esta usa mulberry32, pero la lección es la misma.Míralo
El atacante prueba cada segundo
function resetToken() { return randomBytes(32).toString("base64url");}
function code() { const n = randomInt(1_000_000); return String(n).padStart(6, "0");}function code(random) { const n = Math.floor(random() * 1_000_000); return String(n).padStart(6, "0");}
function predictNext(seen, from, window) { for (let i = 0; i < window; i++) { const guess = mulberry32(from + i); if (code(guess) === seen) { console.log(`Semilla: intento ${i + 1}`); return code(guess); } } return null;}function resetToken() { return randomBytes(32).toString("base64url");}
function code() { const n = randomInt(1_000_000); return String(n).padStart(6, "0");}Paso a paso, en palabras
- La tienda hace sus códigos con un generador con semilla, y la semilla es el segundo en que arrancó: 03:40:17. El primer código va al boletín, también al atacante.
- El siguiente número del mismo generador es el código para restablecer la contraseña de un comprador. Va por correo.
- El atacante sabe que la tienda se reinició por la noche. Prueba cada segundo desde las 03:00: un generador por segundo.
- En el intento 2418 el primer código es igual a su código de descuento. Esa es la semilla: 03:40:17.
- El siguiente número de su copia es el código de restablecimiento del comprador. Cambia la contraseña y se queda con la cuenta.
- El arreglo: los bytes vienen del generador criptográfico del sistema operativo. No hay semilla que adivinar.
- 3600 intentos o 3 millones: nada. El token son 32 bytes aleatorios, y hasta un código de 6 cifras ya no es predecible.
// Math.random no tiene semilla: la tienda usa mulberry32function mulberry32(seed) { return function next() { let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}function code(random) { const n = Math.floor(random() * 1_000_000); return String(n).padStart(6, "0");}
function predictNext(seen, from, window) { for (let i = 0; i < window; i++) { const guess = mulberry32(from + i); if (code(guess) === seen) { console.log(`Semilla: intento ${i + 1}`); return code(guess); } } return null;}const night = 1_793_070_000; // 27.10.2026, 03:00 UTCconst started = night + 2_417; // la tienda se reinició a las 03:40:17const random = mulberry32(started);const discount = code(random);const reset = code(random);console.log(`Código de descuento del boletín (lo tiene el atacante): ${discount}`);const predicted = predictNext(discount, night, 3_600);console.log(`Código predicho para restablecer la contraseña: ${predicted}`);console.log(`El código real, enviado al comprador: ${reset}`);Código de descuento del boletín (lo tiene el atacante): 507214Semilla: intento 2418Código predicho para restablecer la contraseña: 990641El código real, enviado al comprador: 990641Los códigos son distintos en los cuatro lenguajes, porque los generadores son distintos. El intento es el mismo: 2418, porque la semilla es 03:40:17.
La idea
El generador criptográfico en cada lenguaje
function resetToken() { return randomBytes(32).toString("base64url");}
function code() { const n = randomInt(1_000_000); return String(n).padStart(6, "0");}const first = resetToken();const second = resetToken();console.log(`Longitud del token: ${first.length}`);console.log(`Solo A–Z, a–z, 0–9, - y _: ${/^[A-Za-z0-9_-]+$/.test(first)}`);console.log(`Dos tokens seguidos son distintos: ${first !== second}`);console.log(`El código tiene 6 cifras: ${/^[0-9]{6}$/.test(code())}`);Longitud del token: 43Solo A–Z, a–z, 0–9, - y _: trueDos tokens seguidos son distintos: trueEl código tiene 6 cifras: trueEl token son 32 bytes (256 bits), escritos en Base64 seguro para URL: 43 caracteres que caben en un enlace. Adivinarlo es tan difícil como adivinar una clave AES-256.
randomBytes, randomInt y randomUUID de node:crypto son criptográficos. En el navegador lo equivalente es crypto.getRandomValues() y crypto.randomUUID().Pruébalo
Predice la salida
Dos generadores con la misma semilla. ¿Qué imprimirá?
const a = mulberry32(42);const b = mulberry32(42);console.log(Math.floor(a() * 100) === Math.floor(b() * 100));console.log(Math.floor(a() * 100) === Math.floor(b() * 100));La misma semilla da la misma secuencia, número por número. Así que los segundos números también son iguales. En esto se apoya justo el atacante: su copia corre al mismo paso que el generador de la tienda.
Pruébalo
Completa
Un token para restablecer la contraseña del generador criptográfico:
const token = (32).toString("base64url");randomBytes(32) de node:crypto da 32 bytes criptográficamente aleatorios.Pruébalo
Encuentra el error
El token es largo y parece seguro. ¿Qué línea lo hace predecible?
Elige la línea con el error.
Math.random() no es criptográfico: su estado se puede recuperar a partir de unas pocas salidas. Lo correcto es randomBytes(32).toString("base64url").La idea
Una comparación que no revela nada
Cuando la tienda comprueba el código del correo, lo compara con el guardado. Una comparación normal de cadenas se detiene en la primera diferencia. Cuantos más caracteres iniciales son correctos, más tarda. Por la red la diferencia son nanosegundos, pero con muchos intentos y estadística se puede medir. Así el atacante adivina carácter por carácter: 10 intentos para el primero, 10 para el segundo…
El programa de abajo no mide tiempo: cuenta cuántos caracteres mira la comparación, que es lo que hace que el tiempo cambie.
// Qué hace === por dentro: se detiene en la primera diferenciafunction comparedChars(secret, guess) { let i = 0; while (i < secret.length && secret[i] === guess[i]) { i++; } return Math.min(i + 1, secret.length);}const secret = "481937";for (const guess of ["000000", "400000", "480000", "481000"]) { console.log(`${guess}: caracteres comparados ${comparedChars(secret, guess)}`);}for (const guess of ["481000", "481937"]) { const same = timingSafeEqual(Buffer.from(secret), Buffer.from(guess)); console.log(`${guess}: timingSafeEqual = ${same}`);}000000: caracteres comparados 1400000: caracteres comparados 2480000: caracteres comparados 3481000: caracteres comparados 4481000: timingSafeEqual = false481937: timingSafeEqual = trueUna comparación en tiempo constante mira cada byte, encuentre lo que encuentre, y el tiempo no depende de cuánto se ha adivinado. Cada lenguaje la trae hecha: crypto.timingSafeEqual. Úsala para todo lo secreto que llega de fuera: códigos, tokens, firmas de JWT, HMAC. timingSafeEqual lanza un error con longitudes distintas, así que comprueba antes la longitud (no es secreta).
La idea
Cifrado: un algoritmo ya hecho de una biblioteca ya hecha
La tienda guarda el IBAN de los compradores que devuelven mercancía. En la base de datos tiene que estar cifrado, y al leerlo hay que estar seguro de que nadie lo cambió. Las dos cosas las hace el cifrado autenticado. El estándar es AES-GCM: cifra y añade un tag de 16 bytes. Si cambia un solo bit, el tag no coincide y el descifrado se detiene con un error.
AES-GCM está en la biblioteca estándar:function encrypt(key, nonce, text) { const cipher = createCipheriv("aes-256-gcm", key, nonce); const box = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]); return Buffer.concat([box, cipher.getAuthTag()]);}
function decrypt(key, nonce, sealed) { const decipher = createDecipheriv("aes-256-gcm", key, nonce); decipher.setAuthTag(sealed.subarray(-16)); const plain = decipher.update(sealed.subarray(0, -16)); return Buffer.concat([plain, decipher.final()]).toString("utf8");}const key = randomBytes(32); // en la tienda real: del almacén de secretosconst nonce = randomBytes(12); // uno nuevo para cada mensajeconst box = encrypt(key, nonce, "BG80 BNBG 9661 1020 3456 78");console.log(`Cifrado: ${box.length} bytes`);console.log(`Descifrado: ${decrypt(key, nonce, box)}`);box[5] ^= 1;try { decrypt(key, nonce, box);} catch { console.log("Un byte cambiado: la comprobación del tag falló, rechazo");}Cifrado: 43 bytesDescifrado: BG80 BNBG 9661 1020 3456 78Un byte cambiado: la comprobación del tag falló, rechazoMíralo
Un esquema propio, roto en pocas líneas
Un compañero decide que AES es «demasiado complicado» y escribe su propio cifrado: cada byte XOR un byte de una palabra secreta. Parece ruido. Pero XOR se deshace a sí mismo, y toda factura empieza con las mismas palabras:
function xor(data, key) { return Buffer.from(data.map((b, i) => b ^ key[i % key.length]));}const key = Buffer.from("SHOP2026"); // la clave «secreta» de la tiendaconst invoice = xor(Buffer.from("FACTURA 2026-0142, total 249,90 €"), key);const iban = xor(Buffer.from("BG80 BNBG 9661 1020 3456 78"), key);// el atacante: toda factura empieza asíconst known = Buffer.from("FACTURA ");const found = xor(invoice.subarray(0, 8), known.subarray(0, 8));console.log(`La clave es: ${found.toString()}`);console.log(`Otro mensaje: ${xor(iban, found).toString()}`);La clave es: SHOP2026Otro mensaje: BG80 BNBG 9661 1020 3456 78La palabra secreta sale de los primeros 8 bytes de una factura, y con ella se puede leer todo lo que se cifró alguna vez. No hacen falta matemáticas ni miles de millones de intentos, solo un comienzo conocido del texto.
Esa es la regla de esta lección: no inventes tu propia criptografía. Ni tu propio algoritmo, ni tu propio esquema alrededor de un algoritmo ya hecho, ni tu propio generador. La gente que hace criptografía publica sus esquemas y espera años a que otros los rompan. Tú usas su trabajo a través de una biblioteca.
Más precisamente, para curiosos
Cuál para qué.
- Contraseñas: un hash lento con sal (bcrypt, argon2id), la lección sobre contraseñas. Nunca cifrado: una contraseña no debe poder leerse de vuelta.
- Firmar un mensaje con una clave compartida: HMAC-SHA256, como en JWT.
- Cifrar datos: AES-GCM (o ChaCha20-Poly1305) con una clave de un almacén de secretos.
- Tokens y códigos: el generador criptográfico.
- Un hash para la integridad de un archivo: SHA-256 o SHA-512, como en la lección sobre dependencias. MD5 y SHA-1 están rotos para esto.
Bibliotecas de más alto nivel. AES-GCM sigue exigiendo que nunca repitas un nonce y que cuides la clave. Bibliotecas como libsodium (NaCl), Google Tink y Fernet en la cryptography de Python esconden también estas decisiones: obtienes encrypt(key, text) y decrypt(key, box), sin modos y sin nonce.
La comparación en tiempo constante por dentro. Compara cada byte y junta las diferencias con XOR y OR: diff |= a[i] ^ b[i], y al final comprueba diff == 0. No hay salida anticipada. No la escribes tú: el compilador podría «optimizarla»; las funciones ya hechas están construidas para que eso no pueda pasar.
El código de 6 cifras. Incluso de un generador criptográfico, 6 cifras son un millón de posibilidades. Por eso el código caduca en minutos y se bloquea tras unos pocos intentos fallidos. Un token en un enlace tiene 32 bytes, y ahí esos límites no hacen falta contra el adivinar, solo contra las filtraciones (en un registro, por ejemplo).
UUID. UUID.randomUUID() en Java, uuid.uuid4() en Python y crypto.randomUUID() en Node vienen de un generador criptográfico. Guid.NewGuid() en .NET también, en la práctica, pero la documentación no lo promete, así que para secretos usa RandomNumberGenerator. Un UUID en sí son 122 bits aleatorios: bien para un id, pero un token de 32 bytes es más claro.
Ejercicio
Códigos de descuento
Resuélvelo en tu propio editor.
La tienda quiere códigos de descuento de 10 caracteres que se lean fácil: solo letras latinas mayúsculas y cifras, sin 0, O, 1 ni I, que se confunden. El alfabeto es ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 caracteres, 50 bits por código). Escribe discountCode con el generador criptográfico, haz 1000 códigos y comprueba que todos son del alfabeto y todos son distintos:
Códigos: 1000Cada uno tiene 10 caracteres del alfabeto: trueDistintos: 1000Más tareas
- Caducidad. Guarda cuándo se hizo cada código y rechaza los códigos de más de 15 minutos.
- Intentos. Bloquea el código tras 5 intentos fallidos y cuenta los intentos.
- Sin módulo. ¿Por qué
ALPHABET[randomByte % 32]está bien, peroALPHABET[randomByte % 30]no? (Pista: 256 se divide entre 32, pero no entre 30.)
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Funciones flecha · Contraseñas · JWT y sus trampas · Secretos en el código y en git
- Ejercicios sobre el tema: Seguridad para programadores