Ir al contenido
wannacode

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 new Random(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.

Míralo

El atacante prueba cada segundo

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
Paso a paso, en palabras
  1. La tienda hace sus códigos con Random, y la semilla es el segundo en que arrancó: 03:40:17. El primer código va al boletín, también al atacante.
  2. El siguiente número del mismo generador es el código para restablecer la contraseña de un comprador. Va por correo.
  3. El atacante sabe que la tienda se reinició por la noche. Prueba cada segundo desde las 03:00: un generador por segundo.
  4. En el intento 2418 el primer código es igual a su código de descuento. Esa es la semilla: 03:40:17.
  5. 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.
  6. El arreglo: los bytes vienen del generador criptográfico del sistema operativo. No hay semilla que adivinar.
  7. 3600 intentos o 3 millones: nada. El token son 32 bytes aleatorios, y hasta un código de 6 cifras ya no es predecible.
static String code(Random random) {
return String.format("%06d", random.nextInt(1_000_000));
}
static String predictNext(String seen, long from,
int window) {
for (int i = 0; i < window; i++) {
Random guess = new Random(from + i);
if (code(guess).equals(seen)) {
System.out.println("Semilla: intento " + (i + 1));
return code(guess);
}
}
return null;
}
long night = 1_793_070_000L; // 27.10.2026, 03:00 UTC
long started = night + 2_417; // la tienda se reinició a las 03:40:17
Random random = new Random(started);
String discount = code(random);
String reset = code(random);
System.out.println("Código de descuento del boletín (lo tiene el atacante): " + discount);
String predicted = predictNext(discount, night, 3_600);
System.out.println("Código predicho para restablecer la contraseña: " + predicted);
System.out.println("El código real, enviado al comprador: " + reset);
Salida
Código de descuento del boletín (lo tiene el atacante): 727726
Semilla: intento 2418
Código predicho para restablecer la contraseña: 807592
El código real, enviado al comprador: 807592

Los 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

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
String first = resetToken();
String second = resetToken();
System.out.println("Longitud del token: " + first.length());
System.out.println("Solo A–Z, a–z, 0–9, - y _: " + first.matches("[A-Za-z0-9_-]+"));
System.out.println("Dos tokens seguidos son distintos: " + !first.equals(second));
System.out.println("El código tiene 6 cifras: " + code().matches("\\d{6}"));
Salida
Longitud del token: 43
Solo A–Z, a–z, 0–9, - y _: true
Dos tokens seguidos son distintos: true
El código tiene 6 cifras: true

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

SecureRandom se crea una vez y se reutiliza: el primer uso reúne aleatoriedad del sistema y puede ser lento. UUID.randomUUID() también usa SecureRandom.

Pruébalo

Predice la salida

Dos generadores con la misma semilla. ¿Qué imprimirá?

Random a = new Random(42);
Random b = new Random(42);
System.out.println(a.nextInt(100) == b.nextInt(100));
System.out.println(a.nextInt(100) == b.nextInt(100));
Respuesta

Pruébalo

Completa

Un token para restablecer la contraseña del generador criptográfico:

static final SecureRandom RANDOM = new ();

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.

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 equals por dentro: se detiene en la primera diferencia
static int comparedChars(String secret, String guess) {
int i = 0;
while (i < secret.length() && secret.charAt(i) == guess.charAt(i)) {
i++;
}
return Math.min(i + 1, secret.length());
}
String secret = "481937";
for (String guess : List.of("000000", "400000", "480000", "481000")) {
System.out.println(guess + ": caracteres comparados " + comparedChars(secret, guess));
}
byte[] expected = secret.getBytes(StandardCharsets.UTF_8);
for (String guess : List.of("481000", "481937")) {
byte[] given = guess.getBytes(StandardCharsets.UTF_8);
System.out.println(guess + ": MessageDigest.isEqual = " + MessageDigest.isEqual(expected, given));
}
Salida
000000: caracteres comparados 1
400000: caracteres comparados 2
480000: caracteres comparados 3
481000: caracteres comparados 4
481000: MessageDigest.isEqual = false
481937: MessageDigest.isEqual = true

Una 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: MessageDigest.isEqual. Úsala para todo lo secreto que llega de fuera: códigos, tokens, firmas de JWT, HMAC.

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:
static byte[] encrypt(SecretKey key, byte[] nonce, String text) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return cipher.doFinal(text.getBytes(StandardCharsets.UTF_8));
}
static String decrypt(SecretKey key, byte[] nonce, byte[] box) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return new String(cipher.doFinal(box), StandardCharsets.UTF_8);
}
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey(); // en la tienda real: del almacén de secretos
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce); // uno nuevo para cada mensaje
byte[] box = encrypt(key, nonce, "BG80 BNBG 9661 1020 3456 78");
System.out.println("Cifrado: " + box.length + " bytes");
System.out.println("Descifrado: " + decrypt(key, nonce, box));
box[5] ^= 1;
try {
decrypt(key, nonce, box);
} catch (AEADBadTagException e) {
System.out.println("Un byte cambiado: la comprobación del tag falló, rechazo");
}
Salida
Cifrado: 43 bytes
Descifrado: BG80 BNBG 9661 1020 3456 78
Un byte cambiado: la comprobación del tag falló, rechazo
El IBAN cifrado son 27 bytes de texto y 16 bytes de tag. La clave son 32 bytes aleatorios, y en la tienda real viene del almacén de secretos, como en la lección sobre secretos. El nonce son 12 bytes aleatorios, nuevos para cada mensaje: dos textos con una clave y un nonce revelan la relación entre ellos. El nonce no es secreto y se guarda junto al texto cifrado.

Mí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:

static byte[] xor(byte[] data, byte[] key) {
byte[] out = new byte[data.length];
for (int i = 0; i < data.length; i++) {
out[i] = (byte) (data[i] ^ key[i % key.length]);
}
return out;
}
byte[] key = "SHOP2026".getBytes(StandardCharsets.UTF_8); // la clave «secreta» de la tienda
byte[] invoice = xor("FACTURA 2026-0142, total 249,90 €".getBytes(StandardCharsets.UTF_8), key);
byte[] iban = xor("BG80 BNBG 9661 1020 3456 78".getBytes(StandardCharsets.UTF_8), key);
// el atacante: toda factura empieza así
byte[] known = "FACTURA ".getBytes(StandardCharsets.UTF_8);
byte[] found = xor(Arrays.copyOf(invoice, 8), Arrays.copyOf(known, 8));
System.out.println("La clave es: " + new String(found, StandardCharsets.UTF_8));
System.out.println("Otro mensaje: " + new String(xor(iban, found), StandardCharsets.UTF_8));
Salida
La clave es: SHOP2026
Otro mensaje: BG80 BNBG 9661 1020 3456 78

La 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:

Salida
Códigos: 1000
Cada uno tiene 10 caracteres del alfabeto: true
Distintos: 1000

Má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, pero ALPHABET[randomByte % 30] no? (Pista: 256 se divide entre 32, pero no entre 30.)

Test

Ponte a prueba

1. ¿Por qué un código de un Random con semilla es peligroso para restablecer una contraseña, aunque tenga 32 caracteres?
2. ¿Cuál es un generador criptográfico?
3. ¿Por qué el token se compara con MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual y no con ==?
4. La tienda «cifra» las facturas con XOR y una palabra secreta de 8 letras. Toda factura empieza con "FACTURA ". ¿Qué tan difícil es encontrar la palabra?

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