Criptografía que no escribes tú
En resumen: La criptografía no la escribes tú: en Python el módulo secrets da números aleatorios, hmac.compare_digest compara en tiempo constante, y AES viene del paquete cryptography.
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 random.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
def reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"def code(rng): return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window): for i in range(window): guess = random.Random(start + i) if code(guess) == seen: print(f"Semilla: intento {i + 1}") return code(guess) return Nonedef reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"Paso a paso, en palabras
- 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.
- 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.
def code(rng): return f"{rng.randrange(1_000_000):06d}"
def predict_next(seen, start, window): for i in range(window): guess = random.Random(start + i) if code(guess) == seen: print(f"Semilla: intento {i + 1}") return code(guess) return Nonenight = 1_793_070_000 # 27.10.2026, 03:00 UTCstarted = night + 2_417 # la tienda se reinició a las 03:40:17rng = random.Random(started)discount = code(rng)reset = code(rng)print(f"Código de descuento del boletín (lo tiene el atacante): {discount}")predicted = predict_next(discount, night, 3_600)print(f"Código predicho para restablecer la contraseña: {predicted}")print(f"El código real, enviado al comprador: {reset}")Código de descuento del boletín (lo tiene el atacante): 573765Semilla: intento 2418Código predicho para restablecer la contraseña: 271558El código real, enviado al comprador: 271558Los 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
def reset_token(): return secrets.token_urlsafe(32)
def code(): return f"{secrets.randbelow(1_000_000):06d}"first = reset_token()second = reset_token()print(f"Longitud del token: {len(first)}")print(f"Solo A–Z, a–z, 0–9, - y _: {re.fullmatch(r'[A-Za-z0-9_-]+', first) is not None}")print(f"Dos tokens seguidos son distintos: {first != second}")print(f"El código tiene 6 cifras: {re.fullmatch(r'[0-9]{6}', code()) is not None}")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.
El módulosecrets está hecho justo para esto: token_urlsafe, token_hex, token_bytes, randbelow, choice. El módulo random es para simulaciones; su documentación lo dice en un recuadro rojo.Pruébalo
Predice la salida
Dos generadores con la misma semilla. ¿Qué imprimirá?
a = random.Random(42)b = random.Random(42)print(a.randrange(100) == b.randrange(100))print(a.randrange(100) == b.randrange(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:
token = secrets.(32)secrets.token_urlsafe(32) da 32 bytes aleatorios escritos en Base64 seguro para URL.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.
random.choice viene del módulo random: un Mersenne Twister, cuyo estado se puede recuperar con suficientes salidas. Lo correcto es secrets.choice, o directamente secrets.token_urlsafe(32).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 diferenciadef compared_chars(secret, guess): i = 0 while i < len(secret) and secret[i] == guess[i]: i += 1 return min(i + 1, len(secret))secret = "481937"for guess in ["000000", "400000", "480000", "481000"]: print(f"{guess}: caracteres comparados {compared_chars(secret, guess)}")for guess in ["481000", "481937"]: print(f"{guess}: compare_digest = {hmac.compare_digest(secret, guess)}")000000: caracteres comparados 1400000: caracteres comparados 2480000: caracteres comparados 3481000: caracteres comparados 4481000: compare_digest = False481937: compare_digest = 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: hmac.compare_digest. Ú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.
La biblioteca estándar de Python no tiene AES. Tienehashlib, hmac y secrets, pero ningún cifrado. La gente usa el paquete cryptography. Así se ve con él; aquí no se ejecuta, porque la comprobación de código del sitio usa solo la biblioteca estándar:# pip install cryptography: no está en la biblioteca estándarfrom cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(key, nonce, text): return AESGCM(key).encrypt(nonce, text.encode(), None)
def decrypt(key, nonce, box): return AESGCM(key).decrypt(nonce, box, None).decode() # InvalidTag si se cambió algoSolo con la biblioteca estándar puedes protegerte contra la manipulación con HMAC, pero no puedes ocultar el texto:
def seal(key, text): data = text.encode() tag = hmac.new(key, data, hashlib.sha256).digest() return data + tag
def unseal(key, box): data, tag = box[:-32], box[-32:] expected = hmac.new(key, data, hashlib.sha256).digest() if not hmac.compare_digest(tag, expected): raise ValueError("tag") return data.decode()
key = secrets.token_bytes(32) # en la tienda real: del almacén de secretosbox = seal(key, "BG80 BNBG 9661 1020 3456 78")print(f"Sellado: {len(box)} bytes")print(f"Abierto: {unseal(key, box)}")tampered = bytearray(box)tampered[5] ^= 1try: unseal(key, bytes(tampered))except ValueError: print("Un byte cambiado: la comprobación del tag falló, rechazo")Sellado: 59 bytesAbierto: BG80 BNBG 9661 1020 3456 78Un byte cambiado: la comprobación del tag falló, rechazoEl IBAN aquí está a la vista: 27 bytes de texto y un tag de 32 bytes. Es integridad sin secreto. Para el secreto necesitas AES-GCM de cryptography. En la tienda real la clave viene del almacén de secretos, como en la lección sobre secretos.
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:
def xor(data, key): return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))key = "SHOP2026".encode() # la clave «secreta» de la tiendainvoice = xor("FACTURA 2026-0142, total 249,90 €".encode(), key)iban = xor("BG80 BNBG 9661 1020 3456 78".encode(), key)# el atacante: toda factura empieza asíknown = "FACTURA ".encode()found = xor(invoice[:8], known[:8])print(f"La clave es: {found.decode()}")print(f"Otro mensaje: {xor(iban, found).decode()}")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 discount_code 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: 1000secrets.choice(alphabet) elige un carácter.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, 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: Lambdas · Contraseñas · JWT y sus trampas · Secretos en el código y en git
- Ejercicios sobre el tema: Seguridad para programadores