VOLVER A NOTICIAS
defensa #pqc #kyber #dilithium #nextjs #python

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.

por · · 5 min de lectura

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:

TEXT
1shared_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.

TS
1// app/api/kem/route.ts
2import { NextResponse } from 'next/server';
3import { ml_kem768 } from '@noble/post-quantum/ml-kem';
4import { x25519 } from '@noble/curves/ed25519';
5import { hkdf } from '@noble/hashes/hkdf';
6import { sha384 } from '@noble/hashes/sha2';
7import { randomBytes } from '@noble/hashes/utils';
8 
9// Generación de keypair del servidor — hecha una vez, persistida en un KMS en producción.
10function 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 
19export 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 
29export 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:

TS
1// navegador
2async 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.

PYTHON
1# pqc_sign.py — emisión de tokens de API firmados PQ
2from pqcrypto.sign import ml_dsa_65
3from nacl.signing import SigningKey # Ed25519
4import json, time, hashlib, base64
5 
6def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
7def ub64(s): return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
8 
9class 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).

PHP
1<?php
2// pqc_verify.php — verifica un payload con firma híbrida
3declare(strict_types=1);
4 
5require __DIR__ . '/slh_dsa_128s_verify.php'; // PHP puro, ~600 LOC
6// mitad clásica: Ed25519 vía libsodium
7 
8function 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
26header('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 
32if (!verify_hybrid($envelope['msg'], $envelope['sig'], $pub_bundle)) {
33 http_response_code(401);
34 echo json_encode(['err' => 'sig_invalid']);
35 exit;
36}
37echo 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

  1. 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.
  2. 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.
  3. 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.
  4. JWTs híbridos para auth entre servicios. Dentro de tu VPC controlas ambos extremos; el coste de tamaño es irrelevante.
  5. 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=1 y acepta solo híbrido.
  6. Audita y retira tu AES-128. Sed trivial en configs.
  7. 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-quantum es cuidadoso con constant-time; pqcrypto envuelve 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.

// related ops

OPS RELACIONADAS