Toda la criptografía descansa sobre un único cimiento: valores que un atacante no puede predecir. Las claves de cifrado, los tokens de sesión, los salts que acompañarán a las contraseñas (módulo 3) y los IVs que acompañarán al cifrado (módulo 2) — todos son, en el fondo, "números aleatorios". Si la aleatoriedad falla, falla todo lo construido encima, por perfecto que sea el algoritmo: es el equivalente criptográfico de construir un búnker con la puerta abierta. En esta lección entenderás qué es la entropía, la diferencia crítica entre generadores pseudoaleatorios normales (PRNG) y criptográficos (CSPRNG), de dónde saca el sistema operativo su aleatoriedad, y cómo generar valores seguros en Python con secrets y os.urandom — incluyendo el token de recuperación de contraseña para los usuarios de MediNube.
Contenido
- Por qué la aleatoriedad es la base de todo
- Entropía: medir la imprevisibilidad
- PRNG vs CSPRNG
- De dónde saca la entropía el sistema operativo
- En Python:
random(NO) vssecretsyos.urandom(SÍ) - Caso MediNube: token de recuperación de contraseña
- Espacio de claves y bits de seguridad: por qué 2^128 es inalcanzable
Por qué la aleatoriedad es la base de todo
Recuerda la lección anterior: la seguridad del cifrado no reside en el secreto del algoritmo sino en la clave. Eso solo funciona si la clave es imprevisible. Repasemos qué valores impredecibles necesita un sistema como MediNube:
| Valor | Para qué sirve | Qué pasa si es predecible |
|---|---|---|
| Claves de cifrado | Cifrar historiales y documentos | El atacante regenera la clave y descifra todo |
| Tokens de sesión | Identificar al médico logueado | Secuestro de sesiones ajenas |
| Tokens de recuperación de contraseña | Enlace de "he olvidado mi contraseña" | Tomar el control de cualquier cuenta |
| Salts (módulo 3) | Acompañar el hash de cada contraseña | Ataques precalculados contra la tabla de contraseñas |
| IVs / nonces (módulo 2) | Que cifrar dos veces lo mismo no se note | Fugas de patrones en el texto cifrado |
| Identificadores no adivinables | URLs de documentos compartidos | Enumeración de recursos ajenos |
Y no es un riesgo teórico. Dos ejemplos históricos famosos:
- Netscape (1995): el generador de claves de sesión SSL se alimentaba de la hora del día y el ID del proceso. Un atacante que conociera aproximadamente cuándo se creó la conexión reducía las posibilidades a un puñado y rompía la sesión en segundos.
- Debian OpenSSL (2006-2008): un parche eliminó por error casi toda la fuente de entropía del generador; durante casi dos años, las claves SSH y SSL generadas en Debian salían de un espacio de solo 32.767 posibilidades. Se pudieron regenerar todas y romper todas.
En ambos casos los algoritmos de cifrado eran correctos. Falló el cimiento.
Entropía: medir la imprevisibilidad
La entropía (en el sentido de la teoría de la información) mide cuánta incertidumbre tiene un valor para quien intenta adivinarlo, y se expresa en bits. La intuición:
- Un valor con n bits de entropía es tan difícil de adivinar como una secuencia de n lanzamientos de una moneda perfecta: hay 2^n posibilidades igual de probables.
- 1 bit = 2 posibilidades. 10 bits = 1.024. 20 bits ≈ un millón. 128 bits ≈ 3,4 × 10^38.
Lo crucial es que la entropía mide las posibilidades desde el punto de vista del atacante, no el aspecto del valor:
7f3k9x2mparece aleatorio, pero si se generó eligiendo entre 1.000 valores posibles (por ejemplo, a partir de un timestamp de segundos de la última hora), tiene ~10 bits de entropía: se rompe al instante.- La longitud engaña: un UUID versión 1 es largo, pero se construye con la hora y la dirección MAC — mucha longitud, poca entropía. (Los UUID versión 4 sí son aleatorios, con 122 bits.)
- Una contraseña de 20 caracteres formada por dos palabras del diccionario y un año (
clinicasol2026medico) tiene mucha menos entropía que 10 caracteres uniformemente aleatorios.
Regla mental: entropía = log2(número de valores posibles equiprobables). Cuando dudes de un generador, no preguntes "¿qué pinta tiene la salida?" sino "¿cuántos valores distintos pudo producir y podía el atacante estrecharlos?".
PRNG vs CSPRNG
Los ordenadores son máquinas deterministas: no producen azar de la nada. Lo que hacen es expandir una semilla mediante un algoritmo. Y aquí se bifurcan dos mundos:
PRNG (Pseudo-Random Number Generator)
Diseñado para estadística y simulación: que la salida "parezca" uniforme, sea rapidísima y (a menudo) reproducible a voluntad. El módulo random de Python usa Mersenne Twister, excelente para simular dados o barajar datos de prueba... y catastrófico para seguridad:
- Es determinista y recuperable: observando 624 salidas de 32 bits, un atacante reconstruye el estado interno completo y predice todas las salidas futuras (existen herramientas públicas que lo hacen).
- Se puede sembrar explícitamente (
random.seed(42)), y por defecto puede sembrarse con fuentes pobres. La reproducibilidad, virtud en simulación, es veneno en seguridad.
CSPRNG (Cryptographically Secure PRNG)
Diseñado para adversarios: aunque el atacante conozca el algoritmo (Kerckhoffs, lección 01-04) y observe cualquier cantidad de salidas, no puede predecir la siguiente ni reconstruir las anteriores. Se siembra continuamente con entropía real del sistema y su estado interno está protegido.
| Propiedad | PRNG (random) |
CSPRNG (secrets, os.urandom) |
|---|---|---|
| Objetivo de diseño | Buena distribución estadística, velocidad | Imprevisibilidad ante un adversario |
| ¿Predecible observando salidas? | Sí (Mersenne Twister: con 624 salidas) | No (computacionalmente inviable) |
| ¿Sembrable/reproducible? | Sí (seed()) |
No desde la aplicación |
| Fuente de semilla | Puede ser pobre | Entropía del sistema operativo |
| Usos correctos | Simulaciones, juegos, tests, datos de prueba | Claves, tokens, salts, IVs, todo lo de seguridad |
| En Python | random.* |
secrets.*, os.urandom() |
La regla es binaria y sin excepciones: si un atacante gana algo adivinando el valor, CSPRNG. Siempre.
De dónde saca la entropía el sistema operativo
Si el ordenador es determinista, ¿de dónde sale la imprevisibilidad inicial? El sistema operativo recolecta ruido físico del mundo real: microvariaciones de tiempos entre interrupciones de hardware, movimientos de ratón y pulsaciones de teclado, tiempos de acceso a disco, ruido térmico, y en CPUs modernas, instrucciones de hardware dedicadas (como RDRAND en x86). Todas esas fuentes se mezclan en un "pool" de entropía que alimenta un CSPRNG del kernel.
En Linux, ese CSPRNG del kernel se expone de tres maneras:
/dev/urandom: dispositivo clásico; devuelve bytes del CSPRNG sin bloquear. Es la interfaz correcta para prácticamente todo./dev/random: históricamente se bloqueaba cuando "se agotaba" un contador de entropía — una precaución hoy considerada innecesaria (una vez bien sembrado, un CSPRNG no "se gasta"). En kernels modernos (≥ 5.6) se comporta casi igual queurandom.getrandom(): la llamada al sistema moderna (desde Linux 3.17). Comourandom, pero con una garantía extra importante: bloquea solo si el pool aún no se ha inicializado (por ejemplo, en los primeros instantes del arranque de una máquina virtual recién clonada, el único escenario dondeurandompodía ser peligroso). Es lo que usa Python por debajo cuando está disponible.
graph TD
F1[Tiempos de interrupciones] --> P[Pool de entropía del kernel]
F2[Ratón / teclado / disco] --> P
F3[RNG de hardware<br/>RDRAND, TPM] --> P
P --> CS[CSPRNG del kernel]
CS --> U["/dev/urandom"]
CS --> G["getrandom()"]
G --> PY["Python: os.urandom / secrets"]
U --> PY
Para ti como desarrollador la conclusión es tranquilizadora: no gestiones la entropía a mano. No mezcles fuentes propias, no "añadas aleatoriedad" con timestamps. Pide bytes al sistema operativo a través de las APIs correctas y punto. Windows y macOS tienen mecanismos equivalentes (CryptGenRandom/BCryptGenRandom, getentropy), y Python los abstrae todos.
En Python: random (NO) vs secrets y os.urandom (SÍ)
El módulo equivocado para seguridad: random
import random random.seed(1234) # reproducible: ¡útil en tests, fatal en seguridad! print(random.randint(0, 999999)) # 997185 — siempre el mismo con esa semilla print(random.random()) # 0.9664535356921388 — ídem
La propia documentación de Python lo advierte: "los generadores pseudoaleatorios de este módulo no deben usarse con fines de seguridad". Todo lo que salga de random debe considerarse conocido por el atacante.
Los módulos correctos: os.urandom y secrets
os.urandom(n) devuelve n bytes del CSPRNG del sistema (vía getrandom() en Linux moderno). secrets (desde Python 3.6) es una capa de conveniencia sobre la misma fuente, con funciones pensadas exactamente para los casos de seguridad:
import os import secrets # Bytes crudos: la forma de generar material para una futura clave material_clave = os.urandom(32) # 32 bytes = 256 bits print(material_clave.hex()) # p. ej. '9f86d081884c7d659a2f...' # Equivalente con secrets: material_clave = secrets.token_bytes(32) # Bytes representados en hexadecimal (str listo para guardar/mostrar) print(secrets.token_hex(16)) # 16 bytes -> 32 caracteres hex # Bytes en Base64 URL-safe (str listo para meter en una URL) print(secrets.token_urlsafe(32)) # p. ej. 'Drmhze6EPcv0fN_81Bj-nA...' # Elecciones seguras sobre colecciones alfabeto = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # sin caracteres ambiguos (0/O, 1/I) codigo_invitacion = "".join(secrets.choice(alfabeto) for _ in range(10)) # Enteros seguros dado_seguro = secrets.randbelow(6) + 1 # entero uniforme en [1, 6]
Punto por punto:
os.urandom(32)ysecrets.token_bytes(32)son equivalentes; usa el que haga el código más legible. 32 bytes = 256 bits de entropía, el tamaño que usaremos para claves AES-256 en el módulo 2.token_hex(n)ytoken_urlsafe(n)reciben el número de bytes de entropía y devuelven unstrya codificado (¡lección 01-02 en acción: el token son bytes, el hex/Base64 es su embalaje!).token_urlsafe(32)produce ~43 caracteres.secrets.choicees la versión segura derandom.choice;secrets.randbelow(n)da enteros uniformes sin sesgo (evita el clásico error deos.urandom(1)[0] % 6, que introduce sesgo porque 256 no es múltiplo de 6).- ¿Cuánto pedir? Para tokens, 32 bytes (256 bits) es una elección holgada y estándar; 16 bytes (128 bits) es el mínimo serio hoy.
Caso MediNube: token de recuperación de contraseña
El flujo "he olvidado mi contraseña" de MediNube envía por correo un enlace con un token. Si ese token es adivinable, un atacante toma el control de la cuenta de cualquier médico o paciente sin tocar la contraseña. Compara las dos implementaciones:
# ─── VERSIÓN VULNERABLE (encontrada en el código heredado de MediNube) ───
import random, time
def token_recuperacion_MAL(usuario: str) -> str:
random.seed(time.time()) # semilla = hora actual
return f"{usuario}-{random.randint(100000, 999999)}"
# p. ej. 'ana.perez-483920'Problemas, de mayor a menor gravedad:
- Semilla predecible: el atacante sabe (por la cabecera del correo, o simplemente probando) en qué segundo se pidió el token; probando unas decenas de semillas cercanas regenera el token exacto.
- Espacio minúsculo: aunque la semilla fuera perfecta, hay 900.000 valores — ~20 bits de entropía. Un script los prueba todos contra el endpoint en minutos.
- PRNG no criptográfico:
randomes predecible por diseño, como ya sabemos. - Incluye el nombre de usuario, regalando estructura.
# ─── VERSIÓN CORRECTA ───
import secrets
def generar_token_recuperacion() -> str:
"""Token de un solo uso para restablecer contraseña. 256 bits de entropía."""
return secrets.token_urlsafe(32)
token = generar_token_recuperacion()
enlace = f"https://portal.medinube.example/recuperar?token={token}"
# https://portal.medinube.example/recuperar?token=hklR3ZbG8vN2wq...secrets.token_urlsafe(32): 32 bytes del CSPRNG del sistema → 2^256 posibilidades. Imposible de adivinar, imposible de regenerar, sin estructura.- El formato URL-safe permite ponerlo directamente en el enlace del correo (lección 01-02).
- La criptografía resuelve la imprevisibilidad; el resto es lógica de aplicación que también importa: caducidad corta (p. ej. 30 minutos), un solo uso, invalidarlo al usarse. Y como veremos en el módulo 3, en la base de datos conviene guardar un hash del token, no el token mismo — igual que con las contraseñas.
Advertencia: en un sistema sanitario real, el flujo completo de recuperación de cuentas (correo, caducidades, auditoría) debe revisarse con profesionales de seguridad y compliance (RGPD); aquí mostramos solo la pieza criptográfica.
Espacio de claves y bits de seguridad: por qué 2^128 es inalcanzable
Hablamos de "n bits de seguridad" cuando el mejor ataque conocido exige del orden de 2^n operaciones. Para la fuerza bruta pura, n es el tamaño del espacio de claves. La progresión es brutal, porque cada bit duplica el trabajo:
| Bits | Valores posibles | ¿Fuerza bruta viable? |
|---|---|---|
| 20 | ~10^6 | Instantánea en un portátil |
| 40 | ~10^12 | Minutos u horas en un portátil |
| 56 (DES, 1977) | ~7 × 10^16 | Rota en días ya en 1998 (máquina de 250.000 $); hoy trivial |
| 80 | ~10^24 | Al límite de actores estatales; ya no se considera segura |
| 128 | ~3,4 × 10^38 | No, ni remotamente, para nadie |
| 256 | ~10^77 | Margen extra (relevante frente a computación cuántica, módulo 6) |
¿Por qué 2^128 es una barrera física y no solo práctica? Hagamos la cuenta de forma conservadora y generosa con el atacante:
intentos_por_segundo = 10**12 # un billón de claves/s por máquina (muy generoso)
maquinas = 10**9 # mil millones de máquinas en paralelo
segundos_por_anyo = 3.15 * 10**7
anyos = 2**128 / (intentos_por_segundo * maquinas * segundos_por_anyo)
print(f"{anyos:.1e} años") # ~1.1e+10 añosCon mil millones de máquinas probando un billón de claves por segundo cada una, agotar 2^128 claves llevaría del orden de 10.800 millones de años — comparable a la edad del universo (~13.800 millones). Y la energía necesaria para simplemente contar hasta 2^128 con la eficiencia física teórica máxima excede toda la producción energética de la humanidad por márgenes absurdos. Por eso los ataques reales nunca son fuerza bruta contra una buena clave: atacan claves mal generadas (esta lección), algoritmos que filtran patrones (módulo 2), contraseñas humanas de baja entropía (módulo 3) o implementaciones defectuosas (lección 01-04).
Ojo a la letra pequeña: 128 bits de seguridad requieren 128 bits de entropía real. Una clave de 256 bits derivada de una contraseña de 30 bits de entropía es una clave de 30 bits con disfraz — el puente entre contraseñas y claves (KDFs) se construye en el módulo 2 (lección 02-04).
Dos menciones de pasada para que las esperes con el concepto claro: los salts (valores aleatorios públicos que hacen único cada hash de contraseña) se desarrollan en el módulo 3, y los IVs/nonces (valores aleatorios o únicos que hacen único cada cifrado) en el módulo 2. Ambos son consumidores directos de lo aprendido hoy: se generan con el CSPRNG, aunque —a diferencia de las claves— no son secretos.
Errores Comunes y Consejos
- Usar
randompara cualquier cosa de seguridad. El error número uno en Python. Tokens, códigos de verificación, contraseñas temporales, "barajar" opciones de un CAPTCHA: todo eso essecrets. Búsqueda recomendada en tu código:grep -rn "import random"y revisar cada uso. - Sembrar con el tiempo (
seed(time.time())) creyendo que añade seguridad. Es exactamente lo contrario: convierte la semilla en adivinable. Los CSPRNG no se siembran desde la aplicación. - Juzgar la entropía por el aspecto. Un valor largo y "feo" puede tener 10 bits de entropía si el proceso que lo generó tenía pocas posibilidades. Pregunta siempre por el proceso, no por la pinta.
- Reducir la entropía después de generarla. Generar 32 bytes perfectos y luego quedarse con
token[:6]"para que el código quepa en el SMS" deja 6 caracteres. A veces es un compromiso necesario (códigos OTP de 6 dígitos), pero entonces deben compensarse con límites de intentos y caducidad estrictos. - Implementar tu propio generador ("mezclo el timestamp con el PID y le hago un hash"). Es el error de Netscape en 1995. El sistema operativo lo hace bien; úsalo.
- Consejo: estandariza en tu equipo un helper único (p. ej.
secrets.token_urlsafe(32)) para todos los tokens; las revisiones de código se vuelven triviales: cualquier otra fuente de aleatoriedad es una bandera roja.
Ejercicios
Ejercicio 1. Calcula (o razona) los bits de entropía de cada valor y ordénalos de más débil a más fuerte: (a) un PIN de 4 dígitos; (b) un código de 6 letras elegidas uniformemente de un alfabeto de 32 símbolos con secrets.choice; (c) secrets.token_hex(16); (d) un "token" formado por el timestamp Unix en segundos del momento de la petición (el atacante conoce la hora con margen de ±1 hora). Pista: entropía = log2(posibilidades); para (b), cada símbolo de un alfabeto de 32 aporta log2(32) = 5 bits.
Ejercicio 2. En el código heredado de MediNube aparece esta función para generar contraseñas temporales del personal de las clínicas. Identifica todos los problemas y reescríbela correctamente con secrets:
import random, string
def contrasena_temporal():
random.seed() # "para que sea aleatorio"
return "".join(random.choice(string.ascii_lowercase) for _ in range(8))Ejercicio 3. MediNube quiere identificadores no adivinables para las URLs de documentos compartidos (/doc/<id>), de unos 22 caracteres aptos para URL. (a) Escribe la función. (b) Calcula cuántos bits de entropía tiene tu propuesta. (c) Si MediNube genera un millón de identificadores, ¿hay riesgo apreciable de colisión (dos documentos con el mismo id)? Razona con órdenes de magnitud.
Soluciones
Solución 1. (a) PIN de 4 dígitos: 10^4 = 10.000 posibilidades ≈ 13,3 bits — trivial. (d) Timestamp con margen de ±1 hora: 7.200 posibilidades ≈ 12,8 bits — trivial (¡y con estructura!). (b) 6 símbolos × 5 bits = 30 bits ≈ 10^9 posibilidades — débil para un token atacable offline, tolerable solo con límite estricto de intentos online. (c) token_hex(16) = 16 bytes = 128 bits — inalcanzable. Orden: d < a < b < c. Nota: (d) es el peor pese a "parecer" un número grande — el aspecto engaña, el proceso manda.
Solución 2. Problemas: (1) usa random, un PRNG predecible, para un valor de seguridad; (2) random.seed() sin argumento no arregla nada — la salida sigue siendo predecible si se recupera el estado, y el comentario revela una falsa creencia; (3) solo minúsculas: 26^8 ≈ 2 × 10^11 ≈ 37,6 bits — un ataque offline lo agota rápidamente; (4) 8 caracteres es corto para el alfabeto elegido. Versión correcta:
import secrets, string
def contrasena_temporal(longitud: int = 16) -> str:
alfabeto = string.ascii_letters + string.digits # 62 símbolos
return "".join(secrets.choice(alfabeto) for _ in range(longitud))16 caracteres × log2(62) ≈ 95 bits: sobrado para una contraseña temporal, que además debe caducar pronto y forzar el cambio en el primer acceso. (Cómo almacenarla de forma segura es el tema de la lección 03-03.)
Solución 3. (a) La herramienta exacta para "aleatorio + apto para URL" ya la conocemos:
import secrets
def id_documento() -> str:
return secrets.token_urlsafe(16) # 16 bytes -> ~22 caracteres URL-safe(b) La entropía la dan los bytes, no los caracteres: 16 bytes = 128 bits. (c) Riesgo de colisión: con n identificadores en un espacio de 2^128, la probabilidad de alguna colisión es aproximadamente n²/2^129 (paradoja del cumpleaños, que reaparecerá en el módulo 3 con los hashes). Con n = 10^6: (10^6)² / 2^129 ≈ 10^12 / 6,8 × 10^38 ≈ 10^-27 — indistinguible de cero a cualquier efecto práctico. Se pueden generar sin comprobar unicidad, aunque una restricción UNIQUE en la base de datos como cinturón de seguridad nunca sobra.
Conclusión
Ya conoces el cimiento de todo el edificio: la seguridad criptográfica se mide en bits de entropía, que cuentan las posibilidades desde la perspectiva del atacante; los PRNG como random son predecibles por diseño y quedan desterrados de cualquier uso de seguridad; los CSPRNG del sistema operativo (alimentados con ruido físico y expuestos vía /dev/urandom y getrandom()) son la única fuente legítima, y en Python se consumen con secrets y os.urandom. Has visto que 128 bits ponen la fuerza bruta fuera del alcance de la física, has generado material para futuras claves con os.urandom(32) y has sustituido el token de recuperación vulnerable de MediNube por secrets.token_urlsafe(32).
Con esto queda una pregunta en el aire: si el algoritmo puede ser público y toda la seguridad reside en la clave... ¿por qué exactamente es eso una ventaja y no una imprudencia? La respuesta tiene nombre propio —el principio de Kerckhoffs— y de ella se derivan las reglas de oro que gobernarán todo el curso. Es el tema de la última lección de este módulo.
Curso de Criptografía Aplicada
Módulo 1: Fundamentos de la Criptografía
- Qué es la criptografía y para qué sirve
- Codificación, ofuscación y cifrado
- Aleatoriedad y entropía
- El principio de Kerckhoffs y las reglas de oro
Módulo 2: Criptografía Simétrica
- Cifrado simétrico: AES y ChaCha20
- Modos de operación
- Cifrado autenticado (AEAD)
- Derivación de claves (KDF)
Módulo 3: Hashes, MAC y Contraseñas
- Funciones hash criptográficas
- Autenticación de mensajes con HMAC
- Almacenamiento seguro de contraseñas
Módulo 4: Criptografía Asimétrica
- Fundamentos de clave pública y RSA
- Criptografía de curva elíptica
- Firmas digitales
- Intercambio de claves: Diffie-Hellman
- Cifrado híbrido
Módulo 5: PKI, Certificados y TLS
- Certificados X.509 y autoridades de certificación
- TLS en la práctica
- Gestión del ciclo de vida de certificados
