Criptografía post-cuántica en producción: Next.js, Python y PHP, el código que funciona
Qué primitivas PQC desplegar realmente en 2026, por qué híbrido es la única elección honesta hoy, e implementaciones copy-paste de ML-KEM y ML-DSA en tres stacks. Con benchmarks y notas de migración.
TL;DR ¶
NIST finalizó ML-KEM (FIPS 203) para encapsulado de clave, ML-DSA (FIPS 204) para firmas digitales, y SLH-DSA (FIPS 205) para firmas basadas en hash. FALCON (FIPS 206) sigue en borrador. Para 2026, la postura segura es híbrida: primitiva clásica XOReada con una primitiva PQ, de forma que romper el resultado requiera romper ambas. Este post es el código que funciona, en tres stacks, para las cargas más comunes: KEM tipo TLS, firma de documentos, emisión de tokens de API.
Si la librería que coges no dice ML-KEM / ML-DSA, está desactualizada. Los nombres cambiaron desde Kyber / Dilithium cuando aterrizaron los estándares.
1. La shortlist ¶
| Carga | Usar | Evitar |
|---|---|---|
| Intercambio de claves / KEM | ML-KEM-768 (NIST L3) híbrido con X25519 | Kyber-512 puro, RSA puro |
| Firmas digitales | ML-DSA-65 (NIST L3) híbrido con Ed25519 | RSA-PSS, ECDSA aislados |
| Firmas basadas en hash (firmware, raíces) | SLH-DSA-128s (firma pequeña) o SLH-DSA-128f (firma rápida) | XMSS sin higiene de estado |
| Simétrico | AES-256-GCM, ChaCha20-Poly1305 | AES-128 |
| Hash | SHA-384, SHA3-384, BLAKE3 | SHA-256 puro para commits de larga vida |
ML-KEM es lo que cableas en tu TLS, en tu envelope encryption, en el establecimiento de sesión. ML-DSA es lo que cableas en tus JWTs, tu firma de código, tu auth de cliente. SLH-DSA es lo que cableas en cosas que firmas una vez y quieres verificar en 2080.
2. Por qué híbrido ¶
Las primitivas PQ son jóvenes. SIKE (2022) fue candidata NIST ronda 4. En un año se rompió en un portátil. Rainbow (2022) llegó a ronda 3, luego cayó. Los esquemas de retículo de la línea principal (Kyber/Dilithium/Falcon) han sobrevivido a escrutinio fuerte, pero el campo se mueve. Híbrido paga 2× de sobrecarga en cable y elimina el problema del punto único de fallo.
La construcción:
1 shared_secret = KDF( X25519_ss || ML-KEM_ss || transcript )
Ambas entradas están criptográficamente comprometidas. Romper una no le da nada al atacante sin romper también la otra.
3. Next.js — un endpoint KEM híbrido ¶
Usa @noble/post-quantum para ML-KEM y @noble/curves para X25519. Ambas son TypeScript auditado y sin dependencias. El kit en descargas es un Route Handler de Next.js que funciona.
1 // app/api/kem/route.ts 2 import { NextResponse } from 'next/server'; 3 import { ml_kem768 } from '@noble/post-quantum/ml-kem'; 4 import { x25519 } from '@noble/curves/ed25519'; 5 import { hkdf } from '@noble/hashes/hkdf'; 6 import { sha384 } from '@noble/hashes/sha2'; 7 import { randomBytes } from '@noble/hashes/utils'; 8 9 // Generación de keypair del servidor — hecha una vez, persistida en un KMS en producción. 10 function loadServerKeys() { 11 const kemSeed = process.env.PQC_KEM_SEED!; // 64 chars hex 12 const xSeed = process.env.PQC_X25519_SEED!; 13 const kemKp = ml_kem768.keygen(hexToBytes(kemSeed)); 14 const xPriv = hexToBytes(xSeed); 15 const xPub = x25519.getPublicKey(xPriv); 16 return { kemKp, xPriv, xPub }; 17 } 18 19 export async function GET() { 20 // Publica ambas claves públicas; el cliente encapsula contra ambas. 21 const { kemKp, xPub } = loadServerKeys(); 22 return NextResponse.json({ 23 suite: 'hybrid-x25519-mlkem768', 24 kem_pub: bytesToB64(kemKp.publicKey), 25 x_pub: bytesToB64(xPub), 26 }); 27 } 28 29 export async function POST(req: Request) { 30 const body = await req.json(); 31 const { kemKp, xPriv } = loadServerKeys(); 32 33 const kemCt = b64ToBytes(body.kem_ct); 34 const xClient = b64ToBytes(body.x_client_pub); 35 36 const ssKem = ml_kem768.decapsulate(kemCt, kemKp.secretKey); 37 const ssX = x25519.getSharedSecret(xPriv, xClient); 38 39 const transcript = sha384(concat(b64ToBytes(body.kem_ct), xClient)); 40 const sharedSecret = hkdf(sha384, concat(ssX, ssKem), transcript, 'qbit404-hybrid', 32); 41 42 // sharedSecret ya es tu clave de sesión AES-256-GCM 43 return NextResponse.json({ ok: true, sid: bytesToB64(sha384(sharedSecret).slice(0, 16)) }); 44 }
Lado cliente, en el primer contacto:
1 // navegador 2 async function establish(serverPubs: { kem_pub: string; x_pub: string }) { 3 const { ml_kem768 } = await import('@noble/post-quantum/ml-kem'); 4 const { x25519 } = await import('@noble/curves/ed25519'); 5 6 const xClientPriv = crypto.getRandomValues(new Uint8Array(32)); 7 const xClientPub = x25519.getPublicKey(xClientPriv); 8 9 const { cipherText, sharedSecret: ssKem } = 10 ml_kem768.encapsulate(b64ToBytes(serverPubs.kem_pub)); 11 12 const ssX = x25519.getSharedSecret(xClientPriv, b64ToBytes(serverPubs.x_pub)); 13 // ... deriva la clave de sesión idéntica al servidor 14 return { ssKem, ssX, kemCt: cipherText, xClientPub }; 15 }
Tamaño en cable: el ciphertext ML-KEM-768 es de 1088 B, clave pública 1184 B. X25519 añade 32 B. El handshake total está dominado por el KEM. Cachea el bundle público del servidor agresivamente.
Benchmark (M2 MBP, Node 20): keygen 0.6 ms, encaps 0.7 ms, decaps 0.7 ms. Más rápido que ECDH en la misma máquina.
4. Python — servicio de firma ML-DSA ¶
Usa pqcrypto (envuelve las impls de referencia de PQClean). Para producción, prefiere liboqs-python si necesitas artefactos validados NIST-CAVP.
1 # pqc_sign.py — emisión de tokens de API firmados PQ 2 from pqcrypto.sign import ml_dsa_65 3 from nacl.signing import SigningKey # Ed25519 4 import json, time, hashlib, base64 5 6 def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode() 7 def ub64(s): return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) 8 9 class HybridSigner: 10 def __init__(self, ed_seed: bytes, ml_dsa_pub: bytes, ml_dsa_priv: bytes): 11 self.ed = SigningKey(ed_seed) 12 self.pq_pub = ml_dsa_pub 13 self.pq_priv = ml_dsa_priv 14 15 @classmethod 16 def generate(cls, ed_seed: bytes): 17 pq_pub, pq_priv = ml_dsa_65.generate_keypair() 18 return cls(ed_seed, pq_pub, pq_priv) 19 20 def issue(self, claims: dict) -> str: 21 header = {'alg': 'EdDSA+ML-DSA-65', 'typ': 'JWT-HYBRID'} 22 payload = {**claims, 'iat': int(time.time())} 23 h_b = b64(json.dumps(header, separators=(',',':')).encode()) 24 p_b = b64(json.dumps(payload, separators=(',',':')).encode()) 25 msg = f"{h_b}.{p_b}".encode() 26 27 sig_ed = self.ed.sign(msg).signature 28 sig_pq = ml_dsa_65.sign(msg, self.pq_priv) 29 return f"{h_b}.{p_b}.{b64(sig_ed)}.{b64(sig_pq)}" 30 31 def verify(self, token: str) -> dict | None: 32 try: 33 h, p, s_ed, s_pq = token.split('.') 34 msg = f"{h}.{p}".encode() 35 self.ed.verify_key.verify(msg, ub64(s_ed)) 36 ml_dsa_65.verify(msg, ub64(s_pq), self.pq_pub) 37 return json.loads(ub64(p)) 38 except Exception: 39 return None
Tamaño en cable: la firma ML-DSA-65 es de 3293 bytes. Con los 64 bytes de Ed25519 estás en ~4.4 KB de material de firma por token. Para casos JWT esto es grande — tu cabecera auth pasa a ~6 KB tras base64. Considera una cookie de sesión server-side que referencie un token PQ guardado en servidor en vez de empujar el token al navegador.
Benchmark (M2 MBP, Python 3.12): firmar 1.4 ms, verificar 0.6 ms.
5. PHP — mensajes firmados retrocompatibles ¶
PHP 8.x no tiene PQ nativo. Usa openssl_pkey_ed25519 para la mitad clásica y libsodium-php con la extensión experimental ML-KEM, o invoca un binario verificado. El kit en descargas trae un binding FFI a liboqs más un verificador SLH-DSA puro-PHP (solo firmas — la verificación es puro hash, así que es implementable en PHP).
1 <?php 2 // pqc_verify.php — verifica un payload con firma híbrida 3 declare(strict_types=1); 4 5 require __DIR__ . '/slh_dsa_128s_verify.php'; // PHP puro, ~600 LOC 6 // mitad clásica: Ed25519 vía libsodium 7 8 function verify_hybrid(string $message, array $sig, array $pub): bool { 9 // sig: ['ed' => b64, 'slh' => b64] 10 // pub: ['ed' => b64, 'slh' => b64] 11 $ed_ok = sodium_crypto_sign_verify_detached( 12 base64_decode($sig['ed']), 13 $message, 14 base64_decode($pub['ed']) 15 ); 16 if (!$ed_ok) return false; 17 18 return SlhDsa128sVerify::verify( 19 $message, 20 base64_decode($sig['slh']), 21 base64_decode($pub['slh']) 22 ); 23 } 24 25 // Uso de ejemplo en un receptor webhook 26 header('Content-Type: application/json'); 27 $raw = file_get_contents('php://input'); 28 $envelope = json_decode($raw, true); 29 30 $pub_bundle = json_decode(file_get_contents(__DIR__ . '/peer_pubs.json'), true); 31 32 if (!verify_hybrid($envelope['msg'], $envelope['sig'], $pub_bundle)) { 33 http_response_code(401); 34 echo json_encode(['err' => 'sig_invalid']); 35 exit; 36 } 37 echo json_encode(['ok' => true]);
Por qué SLH-DSA en PHP: la verificación es puro hash (SHA-256 por debajo), así que puedes enviar un verificador PHP sin dependencias en hosting compartido. Firmar es más pesado (querrás un proceso firmante en Python/Rust para eso); verificar en webhooks PHP va bien.
Tamaño en cable: la firma SLH-DSA-128s es de 7856 bytes. Grande. Úsalo solo donde el modelo firmado-una-vez-verificado-muchas-veces domine: artefactos de release, SBOMs, procedencia de código.
6. Orden de migración, práctico ¶
- Inventario de cada endpoint TLS, cada clave en tu KMS, cada lugar donde vive un JWT. Cryptography BOM (CBOM). No puedes migrar lo que no puedes encontrar.
- Añade TLS híbrido en el borde (Cloudflare, fastly, ALB) — casi siempre un flag de config en 2026. Ganancia gratis, sin cambios de cliente para navegadores que lo hablen.
- Rota raíces de firma de código a SLH-DSA. Es la migración de mayor impacto porque los artefactos que firmas hoy se verificarán durante 10-20 años.
- JWTs híbridos para auth entre servicios. Dentro de tu VPC controlas ambos extremos; el coste de tamaño es irrelevante.
- Tokens de cara al cliente al final: mantén clásico hasta que las libs del navegador sean uniformes, luego añade una claim
pq=1y acepta solo híbrido. - Audita y retira tu AES-128. Sed trivial en configs.
- Documenta una revisión de migración trimestral. Métela en tu calendario de seguridad; los estándares PQ iterarán.
7. Trampas ¶
- Parámetros equivocados: Kyber-512 es un set NIST L1, bien para handshakes efímeros, no bien para confidencialidad a largo plazo. Por defecto L3 (ML-KEM-768, ML-DSA-65).
- Bigints no constant-time en implementaciones JS: no te lo montes por tu cuenta.
@noble/post-quantumes cuidadoso con constant-time;pqcryptoenvuelve PQClean reference, que es best-effort. - Reuso de estado en esquemas con estado (XMSS): catastrófico. SLH-DSA es sin estado precisamente para esquivar ese footgun.
- Mezclar Kyber pre-estandarización con ML-KEM FIPS 203: no son wire-compatible. Mira las notas de release de tu librería.
- Implementaciones Falcon en punto flotante sobre hardware no IEEE-754: bugs sutiles. Usa el binding FFI a una impl C revisada.
- Generación de números aleatorios: PQ amplifica el coste de un RNG malo. Solo fuentes del SO.
8. Lectura recomendada ¶
- NIST FIPS 203, 204, 205 (2024)
- PQC migration playbook, BSI / ANSSI conjunto (2024)
- Hybrid Key Exchange in TLS 1.3, draft-ietf-tls-hybrid-design
- Bernstein, Buchmann, Dahmen — Post-Quantum Cryptography (libro, sigue siendo el mejor primer)
"Híbrido es lo que eliges cuando no te has decidido. No nos hemos decidido, y no deberías fingir que sí." — Un evaluador de NIST, off the record, 2024.
Los kits en descargas son MIT-licensed y self-contained. Suéltalos en un repo, ejecuta el directorio examples/ incluido, y tienes una superficie PQ funcionando en una tarde.
OPS RELACIONADAS
Dentro de la suite PQC de NIST: ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205)
Cuatro años después de nombrar a los finalistas de ronda 3, los estándares están firmados. Aquí está qué hace cada uno, qué parámetro elegir en cada nivel de seguridad, y la llamada mínima en Python y TypeScript.
Q-day: cómo los analistas fijan la ventana entre 2029 y 2034
Los pronósticos convergen en una ventana de cinco años. Comparamos seis estimaciones publicadas lado a lado, mostramos qué asume cada una, y enviamos una calculadora del teorema de Mosca para que derives tu propio plazo de migración.
Grover muerde a AES: por qué 128 ha muerto y 256 es tu nueva base
El algoritmo de Grover da un speedup cuadrático sobre la búsqueda de clave por fuerza bruta. AES-128 baja a ~64 bits efectivos. AES-256 se queda en ~128 bits. El arreglo es una línea de config — pero hay que encontrar cada sitio donde vive estado de cifrado.