Estas son claves de referencia para el instructor y para autoevaluación. Intenta resolver cada reto y ejercicio por tu cuenta antes de mirar aquí: el valor está en el proceso, no en la respuesta. Puede haber más de una solución correcta; lo que sigue es una guía técnicamente válida.
Volver al índice de la parte: ../classes/parte-2-criptografia-aplicada/README.md
Todos los ejercicios se resuelven en un entorno de laboratorio aislado (VM o contenedor propio), sobre datos propios y sin apuntar herramientas a sistemas de terceros.
Objetivo: romper una sustitución monoalfabética por análisis de frecuencias + bigramas y recuperar ≥90 % del texto.
Pasos:
E A O S R N I D L C T U M P ....DE, LA, QUE, EN, EL) y palabras cortas de 1–2 letras (Y, A, DE, EL).import random, math
from collections import Counter
def puntua(txt, logp, N=4):
s, floor = 0.0, math.log10(0.01/ sum(1 for _ in logp))
for i in range(len(txt)-N+1):
s += logp.get(txt[i:i+N], floor)
return s
def romper(cifrado, logp, iters=20000):
alf = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
mejor = alf[:]; random.shuffle(mejor)
def aplica(k):
t = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "".join(k))
return cifrado.translate(t)
best_s = puntua(aplica(mejor), logp)
for _ in range(iters):
c = mejor[:]; a,b = random.sample(range(26),2); c[a],c[b]=c[b],c[a]
s = puntua(aplica(c), logp)
if s > best_s: best_s, mejor = s, c
return "".join(mejor), aplica(mejor)
Evidencia de cumplimiento: muestra el mapeo deducido y compara carácter a carácter contra el texto plano real; reporta el porcentaje de aciertos (>=90%) sobre un cifrado de ≥500 caracteres.
WKLV LV D WHVW con desplazamiento 3 hacia atrás → THIS IS A TEST.E/A). Reúne las L letras de clave.Objetivo: cifrar la misma imagen BMP con ECB y CBC; ECB debe mostrar la silueta y CBC debe verse como ruido.
Pasos:
cabecera_original || cuerpo_cifrado en dos archivos y ábrelos.# separa cabecera y cuerpo
head -c 54 tux.bmp > head.bin
tail -c +55 tux.bmp > body.bin
openssl enc -aes-128-ecb -nosalt -K 000102030405060708090a0b0c0d0e0f -in body.bin -out ecb_body.bin
openssl enc -aes-128-cbc -nosalt -K 000102030405060708090a0b0c0d0e0f -iv 00112233445566778899aabbccddeeff -in body.bin -out cbc_body.bin
cat head.bin ecb_body.bin > tux_ecb.bmp
cat head.bin cbc_body.bin > tux_cbc.bmp
Evidencia: en tux_ecb.bmp la silueta original sigue reconocible (bloques iguales → cifrados iguales); en tux_cbc.bmp no hay patrón visible. El encadenamiento de CBC hace que cada bloque dependa del anterior, eliminando la estructura.
keystream = E_k(nonce||contador); cifrar y descifrar son la misma operación (XOR con el keystream), por eso no hay padding y E(k)⊕c = m. Verifica que descifrar el cifrado devuelve el texto.openssl speed -evp aes-128-cbc con AES-NI multiplica el rendimiento (GB/s) frente a la implementación software; compara con OPENSSL_ia32cap deshabilitando AES-NI.nonce||i (no del texto previo); CBC encadena cada bloque con el cifrado anterior, obligando a procesar secuencialmente.Objetivo: ataque de nonce reutilizado (crib dragging) para recuperar m2 conocido parcialmente m1, sin la clave.
Fundamento: con (clave, nonce) repetido, c1 ⊕ c2 = m1 ⊕ m2. Si conoces un fragmento de m1 en la posición i, obtienes m2 en esa posición: m2[i] = c1[i] ⊕ c2[i] ⊕ m1[i].
def xor(a, b): return bytes(x ^ y for x, y in zip(a, b))
# c1, c2 cifrados con misma clave y nonce
d = xor(c1, c2) # = m1 XOR m2
crib = b" the " # fragmento conocido de m1 (crib dragging)
for i in range(len(d) - len(crib) + 1):
ventana = xor(d[i:i+len(crib)], crib) # candidato de m2 en esa posición
if all(32 <= c < 127 for c in ventana):
print(i, ventana) # posiciones plausibles (texto imprimible)
Evidencia: recuperas el tramo de m2 solapado con la porción conocida de m1 y muestras que nunca usaste la clave; ampliando el crib con texto plausible (palabras del idioma) extiendes la recuperación.
c1 = m1 ⊕ ks, c2 = m2 ⊕ ks con el mismo keystream ks; entonces c1 ⊕ c2 = (m1⊕ks)⊕(m2⊕ks) = m1 ⊕ m2 (el keystream se cancela).Objetivo: "sobre digital" — cifrar un archivo de varios MB con AES-GCM y proteger la clave AES con RSA-OAEP; el descifrador recupera el original byte a byte y la clave AES nunca aparece en claro en disco.
Pasos:
nonce || tag || ciphertext).clave.enc.import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes, serialization
pub = serialization.load_pem_public_key(open("pub.pem","rb").read())
dek = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
ct = AESGCM(dek).encrypt(nonce, open("grande.bin","rb").read(), None)
enc_dek = pub.encrypt(dek, padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), None))
open("grande.enc","wb").write(nonce + ct)
open("dek.enc","wb").write(enc_dek) # la DEK solo se persiste cifrada
Evidencia: sha256sum del archivo descifrado coincide con el original; en disco solo existen grande.enc y dek.enc (la DEK jamás se escribe en claro).
p=61, q=53: n=3233, φ=60·52=3120, e=17, d=17⁻¹ mod 3120 = 2753. Cifrado de m=65: 65^17 mod 3233 = 2790.e=65537 = 2^16+1: primo, con solo dos bits a 1 → exponenciación rápida (pocas multiplicaciones), evitando además los riesgos de e=3.E(m1)·E(m2) mod n = (m1·m2)^e = E(m1·m2); multiplicar cifrados multiplica los planos, permitiendo manipulación (OAEP lo impide).AESGCM para datos + OAEP(SHA256) para la clave; el patrón estándar de sobre digital.Objetivo: canal seguro — ECDH (X25519) → HKDF → AES-GCM entre dos partes; un tercero que solo ve las claves públicas no puede descifrar.
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
a, b = X25519PrivateKey.generate(), X25519PrivateKey.generate()
sa = a.exchange(b.public_key()) # secreto compartido idéntico
sb = b.exchange(a.public_key())
assert sa == sb
key = HKDF(hashes.SHA256(), 32, None, b"canal-ecdh").derive(sa)
n = os.urandom(12)
ct = AESGCM(key).encrypt(n, b"hola desde A", None)
assert AESGCM(key).decrypt(n, ct, None) == b"hola desde A"
Evidencia: ambos derivan la misma key y descifran los mensajes del otro; un observador con solo las claves públicas no puede calcular el secreto compartido (ECDLP), por lo que su decrypt falla.
P y Q, corta la curva en un tercer punto y refléjalo sobre el eje X → P+Q (para P=Q, usa la tangente).a.exchange(b.pub) == b.exchange(a.pub) → secreto idéntico (ver reto).k aleatorio y único (reusarlo/predecirlo revela la clave); Ed25519 deriva k deterministamente del mensaje+clave, eliminando ese riesgo.Objetivo: verificador de integridad de un directorio con manifiesto SHA-256 que detecte cualquier byte alterado indicando la ruta.
# generar manifiesto
find . -type f ! -name SHA256SUMS -exec sha256sum {} + > SHA256SUMS
# verificar más tarde
sha256sum -c SHA256SUMS # imprime 'OK' o 'FAILED' por archivo
Alternativa en Python: recorre el árbol, calcula SHA-256 por archivo, guarda {ruta: hash} en JSON y en la verificación compara y lista las rutas cuyo hash cambió (o archivos añadidos/borrados).
Evidencia: modifica un solo byte de cualquier archivo y el verificador reporta FAILED con la ruta exacta; sin cambios, todo es OK.
2^(256/2) = 2^128 hashes (inviable).openssl speed/b2sum: BLAKE2b suele ser el más rápido, SHA-256 se acelera con SHA-NI, SHA3-256 (Keccak) suele ir algo más lento en software.Objetivo: canal autenticado (IV, ciphertext, HMAC) que rechace cualquier manipulación antes de descifrar (Encrypt-then-MAC).
import os, hmac, hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
k_enc, k_mac = os.urandom(32), os.urandom(32) # claves separadas
def emisor(m):
iv = os.urandom(16)
p = padding.PKCS7(128).padder(); data = p.update(m) + p.finalize()
ct = Cipher(algorithms.AES(k_enc), modes.CBC(iv)).encryptor().update(data)
tag = hmac.new(k_mac, iv + ct, hashlib.sha256).digest()
return iv, ct, tag
def receptor(iv, ct, tag):
esperado = hmac.new(k_mac, iv + ct, hashlib.sha256).digest()
if not hmac.compare_digest(esperado, tag): # verifica ANTES de descifrar
raise ValueError("MAC inválido: rechazado")
d = Cipher(algorithms.AES(k_enc), modes.CBC(iv)).decryptor().update(ct)
u = padding.PKCS7(128).unpadder(); return u.update(d) + u.finalize()
Evidencia: alterar cualquier byte del IV, del ciphertext o del tag hace fallar compare_digest → rechazo sin descifrar; solo los mensajes íntegros se descifran. La verificación previa impide el padding oracle.
hash(clave||mensaje) con Merkle-Damgård sufre extensión de longitud (se puede añadir sufijo y recalcular el MAC sin la clave); HMAC (doble hash con ipad/opad) lo neutraliza.info distinto).base64url(header).base64url(payload) con HMAC-SHA256; verifica recomputando el HMAC sobre esas dos partes y comparando con la firma.compare_digest devuelve falso; demuestra que el tag ya no coincide.Objetivo: handshake ECDHE autenticado — cada parte firma su clave pública efímera con Ed25519 (clave de largo plazo) y verifica la del otro antes de derivar la clave de sesión; un MITM que altere los efímeros es detectado.
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes, serialization
def parte(id_priv): # id_priv: Ed25519 de largo plazo
eph = X25519PrivateKey.generate()
raw = eph.public_key().public_bytes(serialization.Encoding.Raw,
serialization.PublicFormat.Raw)
firma = id_priv.sign(raw) # firma el efímero
return eph, raw, firma
# cada lado verifica la firma del efímero del otro con su Ed25519 pública conocida
# id_pub_otro.verify(firma_otro, raw_otro) -> lanza si no valida (MITM)
# luego: shared = eph_propio.exchange(X25519PublicKey.from_public_bytes(raw_otro))
# key = HKDF(SHA256, 32, salt, b"handshake").derive(shared)
Evidencia: si un MITM sustituye un efímero, la firma Ed25519 no valida (no posee la clave de largo plazo) → el handshake aborta. Solo con firmas válidas ambas partes derivan la misma clave de sesión.
p=97, g=5: elige a, b; A=5^a mod 97, B=5^b mod 97; secreto = B^a mod 97 = A^b mod 97 (coinciden).g^(xy) = g^(yx) por conmutatividad de la multiplicación en el exponente → ambas partes calculan el mismo valor.A y B, envía su propia pública a cada lado; acaba con dos secretos que él conoce y ninguna de las víctimas lo detecta (sin autenticación).info distintos: HKDF(...info=b"enc") y HKDF(...info=b"mac") → claves independientes.Objetivo: verificador de releases — firma artefactos con Ed25519, publica la clave pública y un script valida cada artefacto contra su firma; los alterados se marcan no confiables.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
import glob, os
# firmar (una vez)
sk = Ed25519PrivateKey.generate()
for f in glob.glob("dist/*"):
open(f + ".sig", "wb").write(sk.sign(open(f, "rb").read()))
# publicar pk (clave pública en raw/PEM)
# verificar
pk = sk.public_key() # en la práctica, cargada del archivo publicado
for f in glob.glob("dist/*"):
if f.endswith(".sig"): continue
try:
pk.verify(open(f + ".sig","rb").read(), open(f,"rb").read())
print("OK", f)
except InvalidSignature:
print("NO CONFIABLE", f)
Evidencia: cualquier artefacto modificado (o con firma inválida) produce InvalidSignature → "NO CONFIABLE"; solo los íntegros pasan. La clave privada nunca se distribuye.
openssl dgst -sha256 -sign priv.pem -sigopt rsa_padding_mode:pss ... para firmar y -verify pub.pem para validar (ver laboratorio de la clase).k en ECDSA para todas las firmas; con dos firmas y k fijo se despeja d (clave privada de firma de código) → jailbreak total.k de hash(clave_privada || mensaje): determinista, sin RNG en la firma → elimina el fallo de nonce reutilizado/predecible..asc + clave pública del proyecto; gpg --verify archivo.asc archivo debe indicar "Good signature" de la clave esperada.Objetivo: PKI de dos niveles (raíz + intermedia); emitir un certificado de servidor firmado por la intermedia y servir la cadena completa.
# Raíz
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 3650 -subj "/CN=Lab Root CA" -out ca.crt
# Intermedia (CSR firmado por la raíz, con CA:TRUE)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out int.key
openssl req -new -key int.key -subj "/CN=Lab Intermediate CA" -out int.csr
openssl x509 -req -in int.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 1825 -sha256 \
-extfile <(printf "basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign") -out int.crt
# Servidor (firmado por la intermedia, con SAN)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out srv.key
openssl req -new -key srv.key -subj "/CN=lab.local" -out srv.csr
openssl x509 -req -in srv.csr -CA int.crt -CAkey int.key -CAcreateserial -days 365 -sha256 \
-extfile <(printf "subjectAltName=DNS:lab.local") -out srv.crt
# Verificación de la cadena
openssl verify -CAfile ca.crt -untrusted int.crt srv.crt # => srv.crt: OK
Evidencia: el comando openssl verify devuelve OK; un cliente TLS que reciba srv.crt || int.crt valida hasta la raíz de confianza.
Subject, Issuer, Validity (notBefore/notAfter), Subject Public Key Info, Serial Number (y extensiones como SAN, Basic Constraints, Key Usage).subjectAltName=DNS:a.lab,DNS:b.lab,IP:10.0.0.1 en -extfile y verifica con openssl verify; los navegadores exigen el nombre en SAN, no en CN.*.google.com) usados para MITM en Irán; resultado: eliminación de su raíz de todos los navegadores y quiebra.Objetivo: servidor TLS de laboratorio que solo acepte TLS 1.3 con cipher suites AEAD y forward secrecy, presentando la cadena de la clase 055.
Pasos:
srv.crt + intermedia) y limita a TLS 1.3.openssl s_server -cert srv.crt -key srv.key -cert_chain int.crt \
-tls1_3 -accept 4443 -www
# comprobación de versión y cipher
openssl s_client -connect lab.local:4443 -tls1_3 -servername lab.local </dev/null | \
grep -E "Protocol|Cipher"
# auditoría
./testssl.sh --protocols --ciphers lab.local:4443
ssl_protocols TLSv1.3; y confía en las cipher suites AEAD por defecto de TLS 1.3 (TLS_AES_128_GCM_SHA256, etc.).Evidencia: testssl.sh no reporta protocolos viejos (SSLv3/TLS 1.0/1.1) ni cifrados débiles (RC4/3DES); la conexión negocia TLS 1.3 con ECDHE (forward secrecy) y el cliente valida la cadena hasta la raíz.
TLS_AES_256_GCM_SHA384: AEAD = AES-256-GCM (cifrado+autenticación); SHA384 = hash del HKDF/PRF; el intercambio de claves (ECDHE) y la firma se negocian aparte en TLS 1.3../testssl.sh host:443; hallazgos típicos: versiones habilitadas, cipher suites, forward secrecy, vulnerabilidades conocidas (BEAST/POODLE/Heartbleed), calidad del certificado.Objetivo: módulo registro/login con Argon2id (salt automático, parámetros calibrados), verificación en tiempo constante y migrador que rehashea credenciales legacy al iniciar sesión.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHash
import hashlib
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
def registrar(pwd): # salt aleatorio automático embebido en el hash
return ph.hash(pwd)
def login(hash_guardado, pwd, es_legacy_sha256=False):
if es_legacy_sha256: # migración transparente de hashes viejos
if hashlib.sha256(pwd.encode()).hexdigest() != hash_guardado:
return None
return ph.hash(pwd) # rehash a Argon2id y persiste el nuevo
try:
ph.verify(hash_guardado, pwd) # comparación en tiempo constante
if ph.check_needs_rehash(hash_guardado):
return ph.hash(pwd) # sube el coste si cambió la política
return hash_guardado
except (VerifyMismatchError, InvalidHash):
return None
Evidencia: dos usuarios con la misma contraseña obtienen hashes distintos (salt único); la verificación acepta la correcta y rechaza la incorrecta; las cuentas SHA-256 legacy se actualizan a Argon2id en el primer login exitoso.
time_cost/memory_cost y mide con timeit hasta ~300 ms por hash en el hardware objetivo de producción.Objetivo: utilidad que genere claves, nonces y tokens solo con CSPRNG, más un test que verifique ausencia de nonces repetidos y distribución de bits ~50/50.
import os, secrets
def material():
return {
"clave": os.urandom(32),
"nonce": os.urandom(12),
"token": secrets.token_urlsafe(32),
}
def test_no_repeticion(n=1_000_000):
vistos = set()
unos = total = 0
for _ in range(n):
v = os.urandom(12)
assert v not in vistos, "nonce repetido!"
vistos.add(v)
b = int.from_bytes(v, "big"); unos += bin(b).count("1"); total += 96
ratio = unos / total
assert 0.49 < ratio < 0.51, ratio # ~50 % de unos
Evidencia: sobre una muestra grande no hay nonces repetidos y la proporción de unos/ceros se desvía menos del umbral; el código no importa random (ningún PRNG estadístico).
random (Mersenne Twister) es un PRNG estadístico predecible si se conoce el estado; secrets/os.urandom usan el CSPRNG del SO → el correcto para seguridad.secrets.token_bytes(32) = 256 bits: espacio de 2^256, imposible de adivinar o colisionar por fuerza bruta.gcd(n1, n2) se factorizan al instante.random.seed(t) con t conocido y muestra que random.getrandbits produce exactamente la misma secuencia → predecible si el atacante conoce el instante.dieharder/frecuencias: el PRNG débil muestra sesgos/patrones; el CSPRNG pasa las pruebas.Objetivo: contenedor de archivos con AES-GCM que autentique metadatos (nombre y versión) en el AAD y verifique integridad al abrir.
import os, json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def empaquetar(ruta, key, nombre, version):
aad = json.dumps({"nombre": nombre, "version": version}, sort_keys=True).encode()
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, open(ruta, "rb").read(), aad)
open(ruta + ".aead", "wb").write(len(aad).to_bytes(2,"big") + aad + nonce + ct)
def abrir(ruta_aead, key):
blob = open(ruta_aead, "rb").read()
la = int.from_bytes(blob[:2], "big")
aad, nonce, ct = blob[2:2+la], blob[2+la:2+la+12], blob[2+la+12:]
return AESGCM(key).decrypt(nonce, ct, aad) # InvalidTag si algo cambió
Evidencia: el archivo se descifra solo con nonce, clave y AAD correctos; alterar un byte del cifrado o de los metadatos del AAD lanza InvalidTag sin exponer datos (fallo cerrado).
b"v1" y descifra con AAD b"v2": lanza InvalidTag → el AAD se autentica aunque no se cifre.m1⊕m2 (como en CTR), sino que permite recuperar la clave de autenticación H y forjar tags → compromete también la integridad, no solo la confidencialidad.openssl speed/benchmarks: AES-GCM gana con AES-NI+PCLMULQDQ; ChaCha20-Poly1305 gana en CPUs sin esas instrucciones (móviles/embebidos).nonce(12) || ciphertext || tag(16) autoexplicativo: el receptor separa por longitudes fijas y descifra.Objetivo: recuperar un texto plano completo de un oráculo de padding de laboratorio sin la clave, y luego mostrar que migrar a AEAD anula el ataque.
Fundamento (CBC + PKCS#7): para cada byte del bloque objetivo, modifica el byte correspondiente del bloque previo C[i-1] hasta que el oráculo diga "padding válido". Entonces el valor intermedio I = C'[i-1] ⊕ padding_esperado, y el texto plano P[i] = I ⊕ C[i-1]_original.
# oracle(iv, ct) -> True si el padding descifrado es válido
def recupera_bloque(prev, blk, oracle):
inter = bytearray(16)
for pad in range(1, 17):
forj = bytearray(16)
for k in range(1, pad): forj[-k] = inter[-k] ^ pad
for b in range(256):
forj[-pad] = b
if oracle(bytes(forj), blk):
inter[-pad] = b ^ pad
break
return bytes(p ^ i for p, i in zip(prev, inter)) # texto plano del bloque
Evidencia: entregas el texto plano recuperado de la versión CBC vulnerable; tras reescribir el servicio con AES-GCM, cualquier manipulación devuelve solo InvalidTag uniforme y el ataque no recupera información.
recupera_bloque a cada par (bloque previo, bloque objetivo) del mensaje; documenta cómo cada byte válido revela un byte del intermedio y de ahí el plano.decrypt verifica el tag antes del padding y falla uniforme → el oráculo desaparece.Objetivo: demostrar empíricamente la paradoja del cumpleaños — para varios n, generar valores aleatorios hasta la primera colisión, repetir y comparar la media con 2^(n/2).
import os, statistics
def hasta_colision(n_bits):
vistos, c = set(), 0
while True:
v = int.from_bytes(os.urandom(8), "big") % (1 << n_bits)
c += 1
if v in vistos: return c
vistos.add(v)
for n in (16, 24, 32):
medias = [hasta_colision(n) for _ in range(200)]
print(n, round(statistics.mean(medias)), "~2^(n/2)=", 2 ** (n/2))
Evidencia: la media de intentos hasta colisión queda en el orden de 2^(n/2) (constante ≈ 1.25·√(2^n)); se concluye que un hash de n bits ofrece solo ~n/2 bits frente a colisiones (por eso SHA-256 da ~128).
n=32, genera valores de 32 bits hasta colisión: la media ronda 2^16 ≈ 65.536, no 2^32.2^128 ≈ 3.4×10^38 operaciones; incluso a 10^18 ops/s son ~10^13 años → inviable.2^56 es forzable por hardware moderno en horas/días; AES-128 (2^128) queda fuera de alcance por muchos órdenes de magnitud.Objetivo: intercambio de claves híbrido (X25519 + KEM post-cuántico) que derive la clave de sesión con HKDF y cifre con AES-GCM; documentar por qué sigue seguro si una familia cae.
Estrategia: concatena los dos secretos compartidos y pásalos por HKDF (no XOR simple), de modo que romper el canal exija romper ambos esquemas.
# Requiere oqs (liboqs-python) para ML-KEM; X25519 vía cryptography
import oqs, os
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Clásico
a = X25519PrivateKey.generate()
# ... intercambio de claves públicas ...
# ss_x = a.exchange(pub_b)
# Post-cuántico (ML-KEM / Kyber)
with oqs.KeyEncapsulation("ML-KEM-768") as kem:
pk = kem.generate_keypair()
ct, ss_pq = oqs.KeyEncapsulation("ML-KEM-768").encap_secret(pk)
# ss_pq_dec = kem.decap_secret(ct) # el otro lado
# Combinación híbrida: HKDF sobre ss_x || ss_pq
# secreto = ss_x + ss_pq
# key = HKDF(hashes.SHA256(), 32, None, b"hibrido").derive(secreto)
# AESGCM(key).encrypt(os.urandom(12), b"mensaje", None)
Evidencia: ambas partes derivan la misma key y descifran; se documenta que, al mezclar ambos secretos en el HKDF, un adversario que rompa solo X25519 (cuántica) o solo ML-KEM sigue sin conocer la clave de sesión.
Objetivo: envelope encryption — DEK por objeto cifrando datos con AES-GCM y protegiendo la DEK con el motor transit de Vault; solo se persisten el dato cifrado y la DEK cifrada; rotar la KEK no impide leer lo antiguo.
vault server -dev &
export VAULT_ADDR='http://127.0.0.1:8200'
vault secrets enable transit
vault write -f transit/keys/kek-maestra
import os, base64, requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
H = {"X-Vault-Token": os.environ["VAULT_TOKEN"]}
BASE = "http://127.0.0.1:8200/v1/transit"
def guardar(dato):
dek = AESGCM.generate_key(bit_length=256)
n = os.urandom(12)
ct = AESGCM(dek).encrypt(n, dato, None)
# cifra la DEK con la KEK de Vault (la DEK en claro nunca se persiste)
r = requests.post(f"{BASE}/encrypt/kek-maestra", headers=H,
json={"plaintext": base64.b64encode(dek).decode()})
return {"nonce": n, "ct": ct, "dek_cifrada": r.json()["data"]["ciphertext"]}
Evidencia: para leer se pide a Vault descifrar dek_cifrada (endpoint transit/decrypt), se reconstruye la DEK en memoria y se descifra el dato; la DEK en claro nunca se escribe; tras transit/keys/kek-maestra/rotate los datos viejos siguen descifrándose (Vault conserva versiones anteriores de la KEK).
vault kv put secret/app clave=valor y desde el script vault kv get -field=clave secret/app (o vía API con el token).gitleaks/git-secrets/trufflehog escanean el árbol y el histórico buscando patrones (claves AWS, tokens) y entropía alta.Objetivo: herramienta que cifre un mensaje con AES-GCM y lo oculte por LSB en una imagen PNG, más un extractor que recupere y descifre; describir la señal estadística que delata la ocultación.
from PIL import Image
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def ocultar(cover, salida, mensaje, key):
nonce = os.urandom(12)
payload = nonce + AESGCM(key).encrypt(nonce, mensaje, None)
payload = len(payload).to_bytes(4, "big") + payload # prefijo de longitud
bits = "".join(f"{b:08b}" for b in payload)
img = Image.open(cover).convert("RGB"); px = img.load()
i = 0
for y in range(img.height):
for x in range(img.width):
r, g, b = px[x, y]
if i < len(bits): r = (r & ~1) | int(bits[i]); i += 1
px[x, y] = (r, g, b)
img.save(salida, "PNG") # PNG: sin pérdida, preserva el LSB
Evidencia: el extractor lee los LSB del canal rojo, reconstruye longitud || nonce || ct, descifra con AES-GCM y recupera el mensaje intacto solo con la clave correcta; la imagen se ve idéntica. Señal delatora: el LSB deja de ser aleatorio → un test chi-cuadrado o el análisis de pares de valores (Sample Pairs) detecta la anomalía estadística.
.zip cifrado es evidente; el mismo dato en el LSB de una foto, no.Objetivo: corregir un módulo con ≥5 fallos criptográficos aplicando AEAD, nonces del CSPRNG, claves fuera del código, Argon2id y comparación en tiempo constante, con versión de formato para criptoagilidad.
Fallos del fragmento vulnerable y su corrección:
| Fallo | Corrección |
|---|---|
Clave hardcodeada KEY = b"..." |
Cárgala de variable de entorno / KMS / Vault |
modes.ECB() |
AES-GCM (AEAD) con nonce único |
IV fijo b"\x00"*16 |
Nonce de 12 B con os.urandom por mensaje |
random.random() para tokens |
secrets.token_urlsafe() |
Comparación a == b |
hmac.compare_digest(a, b) |
md5(p) para contraseña |
Argon2id (argon2-cffi) |
import os, secrets, hmac
from argon2 import PasswordHasher
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
VERSION = b"v1" # criptoagilidad: prefijo de formato
KEY = bytes.fromhex(os.environ["APP_KEY"]) # clave fuera del código
ph = PasswordHasher()
def cifrar(m):
n = os.urandom(12)
return VERSION + n + AESGCM(KEY).encrypt(n, m, VERSION) # AAD = versión
def token(): return secrets.token_urlsafe(32)
def check(a, b): return hmac.compare_digest(a, b)
def pwd_hash(p): return ph.hash(p)
Evidencia: bandit no reporta fallos cripto de severidad media/alta en el módulo corregido; los tests de cifrado/descifrado y de autenticación pasan; gitleaks/grep confirman que ninguna clave o secreto aparece en el código.
random para tokens → secrets; == de secretos → compare_digest; MD5/SHA-256 para password → Argon2id; cripto casera → librería auditada.compare_digest, clave desde entorno, versión de formato).bandit archivo.py: marca B303 (MD5), B311 (random inseguro), B105/B106 (secreto hardcodeado); cada advertencia señala uno de los fallos del fragmento.version(2B) || nonce(12B) || ciphertext || tag; el prefijo permite cambiar algoritmo/parámetros sin ambigüedad al descifrar (criptoagilidad).testssl.sh (protocolos/cifrados) y el almacenamiento de contraseñas (que use Argon2id/bcrypt con salt y coste adecuado) → reporta ambos conjuntos de hallazgos.