· 7 min read

Tres barreras entre tú y TLS post-cuántico en nginx de Ubuntu estándar

Habilitar X25519MLKEM768 en Ubuntu 24.04 con oqs-provider parece un cambio de una línea. No lo es. Un reporte de campo sobre los tres puntos de fricción que nadie documenta juntos.

Habilitar X25519MLKEM768 en Ubuntu 24.04 con oqs-provider parece un cambio de una línea. No lo es. Un reporte de campo sobre los tres puntos de fricción que nadie documenta juntos.

Esta es la versión en español de Three walls between you and post-quantum TLS on stock Ubuntu nginx.

Conseguí que curve=X25519MLKEM768 apareciera en un trace de curl contra mi propio origen después de una tarde de debugging. Tres cosas se interpusieron en el camino — ninguna documentada en los READMEs de nginx ni de oqs-provider, y cada una falla en silencio haciendo fallback a X25519 clásico sin registrar ningún error.

Stack: Ubuntu 24.04 LTS, nginx 1.29.8 del repositorio mainline de nginx.org, certificados Let’s Encrypt, detrás de Akamai.

Objetivo: lograr que el origen y un cliente directo negocien X25519MLKEM768 como grupo de intercambio de claves.

Si ves fallback a X25519 sin explicación aparente sin importar lo que pongas en ssl_ecdh_curve, es probable que una de estas sea la causa.

¿Por qué importa?

El modelo de amenaza es el harvest now, decrypt later: capturar tráfico cifrado hoy y descifrarlo cuando exista un ordenador cuántico con suficiente capacidad. La solución es el intercambio de claves híbrido — combinar X25519 con ML-KEM-768 para que romper la sesión requiera romper los dos algoritmos. El IETF ha estandarizado esta combinación como X25519MLKEM768, el NIST estandarizó el algoritmo base en FIPS-203, y Chrome y Firefox lo negocian por defecto.

El algoritmo está implementado en oqs-provider. El problema está en cablearlo correctamente a través del stack de paquetes estándar de Ubuntu.

El stack en un diagrama

The three walls between a stock Ubuntu nginx and a working post-quantum TLS handshake Stack diagram: Client to Akamai edge to Akamai parent, then down to origin server. Inside the origin: nginx workers and OpenSSL 3.0.13 containing default provider and oqsprovider. Three numbered orange markers show where silent TLS fallback occurs. Where the three walls live Ubuntu 24.04 / nginx 1.29.8 / OpenSSL 3.0.13 / oqs-provider 0.7.0 Client Chrome / curl Akamai edge TLS termination Akamai parent Tiered distribution ClientHello: PQ + classical 3 Post Quantum Cryptography to Origin (pqcOrigin) Origin server systemd unit, nginx workers, OpenSSL libs nginx workers env from systemd, not shell 2 OpenSSL 3.0.13 default X25519, P-256 oqsprovider ML-KEM hybrid TLS group registry [ssl_sect] system_default Groups 1 Silent failure points — handshake completes, just not as PQC
  • La barrera 1 está dentro de OpenSSL: conseguir que el provider se cargue y sus grupos queden registrados en la capa TLS.
  • La barrera 2 está en el límite de los worker processes de nginx: conseguir que las variables de entorno correctas lleguen a los workers al arrancar.
  • La barrera 3 está fuera del origen: el tramo parent-to-origin de Akamai tiene su propia configuración independiente.

Barrera 1 — OpenSSL carga el provider pero no expone sus grupos

Ubuntu 24.04 incluye OpenSSL 3.0.13. El oqs-provider compila sin problemas: oqsprovider.so queda en /usr/lib/x86_64-linux-gnu/ossl-modules/oqsprovider.so y openssl list -providers lo reporta como activo. El handshake sigue devolviendo X25519.

El problema es que OpenSSL no expone automáticamente los grupos de un provider a la capa TLS. Hay que configurar dos cosas en /etc/ssl/openssl.cnf, ninguna presente en una instalación estándar de Ubuntu:

  1. El provider debe activarse en [provider_sect].
  2. La lista de grupos TLS debe declararse en [ssl_sect] y [system_default_sect].

Configuración mínima:

# /etc/ssl/openssl.cnf — añadir antes de la sección [default] existente
openssl_conf = openssl_init

[openssl_init]
providers = provider_sect
ssl_conf  = ssl_sect

[provider_sect]
default     = default_sect
oqsprovider = oqsprovider_sect

[default_sect]
activate = 1

[oqsprovider_sect]
activate = 1
module   = /usr/lib/x86_64-linux-gnu/ossl-modules/oqsprovider.so

[ssl_sect]
system_default = system_default_sect

[system_default_sect]
Groups = X25519MLKEM768:X25519:secp256r1:secp384r1

La mayoría de las guías cubren [provider_sect]. El bloque [ssl_sect] es el que registra X25519MLKEM768 en la capa de negociación TLS. Sin él, el provider se carga y el algoritmo está disponible, pero el grupo nunca aparece en un ServerHello.

Un typo en este archivo no genera ningún error al arrancar nginx — solo fallback silencioso a X25519. Me costó una tarde entera un carácter incorrecto en el path de module. El provider falló silenciosamente, nginx arrancó limpio, y cada handshake eligió X25519. La única señal fue openssl list -providers mostrando oqsprovider ausente. Ese comando es ahora un smoke test obligatorio después de cualquier cambio en openssl.cnf.

También útil: openssl list -kem-algorithms -provider oqsprovider debería incluir X25519MLKEM768. Si no aparece, nginx tampoco lo va a encontrar.

Un apunte sobre el alcance de OpenSSL 3.0.x: tiene limitaciones con los algoritmos de firma basados en providers en TLS 1.3, pero para el intercambio de claves KEM — que es lo que necesitamos — 3.0.13 funciona. Las firmas PQ en TLS requieren OpenSSL 3.2+.

Barrera 2 — los worker processes de nginx no ven el entorno del provider

Provider cargado, grupo registrado, ssl_ecdh_curve X25519MLKEM768:X25519:secp256r1; configurado en el sitio. Recargamos nginx, probamos — sigue siendo X25519.

La causa raíz es el aislamiento de entorno: los worker processes no heredan variables del shell ni del entorno estándar de un servicio systemd. OPENSSL_CONF y OPENSSL_MODULES están ausentes a menos que se pasen explícitamente. El provider falla al inicializarse en los workers, TLS hace fallback de forma transparente, y no se registra ningún error.

La solución es la directiva env de nginx con valores explícitos, añadida al nivel raíz de nginx.conf antes del bloque events. La forma env NOMBRE=valor establece la variable incondicionalmente y la propaga a todos los worker processes al hacer fork — a diferencia de env NOMBRE; a secas, que solo pasa una variable que ya esté presente en el entorno del proceso maestro:

# /etc/nginx/nginx.conf — nivel raíz
env OPENSSL_MODULES=/usr/lib/x86_64-linux-gnu/ossl-modules;
env OPENSSL_CONF=/usr/lib/ssl/openssl.cnf;

Ojo con el path de OPENSSL_CONF: /usr/lib/ssl/openssl.cnf es el symlink canónico de Debian a /etc/ssl/openssl.cnf. Cualquiera de los dos funciona, pero el symlink es el que usa el tooling de Debian por defecto.

No hace falta ningún drop-in de systemd. Las dos líneas env son la solución completa. Después de systemctl restart nginx, verificamos que las variables llegan a un worker:

cat /proc/$(pgrep -fo 'nginx: worker')/environ | tr '\0' '\n' | grep OPENSSL

Si no aparece nada, los workers no pueden ver la configuración del provider y nada más va a funcionar hasta que se corrija esto.

Barrera 3 — el tramo parent-to-origin necesita su propia configuración

Superadas las barreras uno y dos, un curl directo confirma X25519MLKEM768. Entonces revisamos el log de auditoría TLS:

2.18.178.209  "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>
2.22.30.5     "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>
2.20.185.26   "GET  / HTTP/2.0" 200 curve=X25519MLKEM768  sni=www.<redacted>
23.200.84.167 "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>
2.20.185.26   "GET  /blog/... HTTP/2.0" 200 curve=X25519MLKEM768 sni=www.<redacted>
2.20.185.26   "GET  /sitemap-index.xml HTTP/2.0" 200 curve=X25519MLKEM768 sni=www.<redacted>
2.19.193.117  "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>

Dos tipos de requests. Los HEAD / son probes de liveness de Akamai GTM (aparecen como FirstFlow agent en el access log) — hacen un handshake TLS mínimo para verificar que el servidor está vivo y negocian X25519 clásico. Los GET son tráfico real reenviado por el parent de Akamai — todos muestran X25519MLKEM768. Esto es lo esperado.

El tramo parent-to-origin está controlado por el behavior Post Quantum Cryptography to Origin (identificador: pqcOrigin) en Akamai Property Manager. Cuando lo configuré por primera vez estaba en Limited Availability. Pasó a GA en el primer trimestre de 2026 y se está habilitando por defecto para propiedades con Enhanced TLS.

Un detalle importante: por defecto pqcOrigin envía X25519 clásico en el primer ClientHello, no la clave híbrida. Esto evita un round-trip con HelloRetryRequest en orígenes que no soporten PQC. Como nuestro origen sí lo soporta después de las barreras uno y dos, hay que habilitar PQC Keys in First ClientHello en el behavior para evitar ese round-trip extra.

PQC de extremo a extremo en Akamai son tres handshakes separados: cliente a edge, edge a parent, parent a origen. Las barreras uno y dos cubren el último tramo. Los dos primeros se controlan en la capa del CDN.

Verificación

Curl directo contra el origen:

curl -v --curves X25519MLKEM768 https://tu-origen.example.com/ 2>&1 \
  | grep -i 'Curve Group\|SSL connection'

La salida esperada incluye [Curve Group] X25519MLKEM768. También confirmamos que el fallback clásico funciona:

curl -v --curves X25519 https://tu-origen.example.com/ 2>&1 | grep 'Curve Group'

Para tener visibilidad continua, añadimos un formato de log dedicado en nginx que capture la curva negociada por request:

log_format tls_audit '$remote_addr - $remote_user [$time_local] '
                     '"$request" $status '
                     'tls=$ssl_protocol cipher=$ssl_cipher '
                     'curve=$ssl_curve sni=$ssl_server_name';

access_log /var/log/nginx/tls_audit.log tls_audit;

Este es el formato detrás de los extractos de log de este post. Es la única forma fiable de confirmar lo que el stack está negociando en cada request, y no solo lo que la configuración debería producir.

Conclusión

El camino con Ubuntu 24.04 estándar funciona: nginx 1.29.8 mainline, OpenSSL 3.0.13, oqs-provider 0.7.0 cargado en tiempo de ejecución. Los dos nodos de origen de este sitio llevan con esta configuración varios reinicios, renovaciones de certbot y ciclos de unattended-upgrades sin regresiones.

Las tres barreras son el mismo problema en capas distintas: los stacks TLS degradan de forma transparente. Un grupo de provider ausente, un entorno de worker sin las variables, un behavior de CDN sin configurar — ninguno produce un error. Cada uno produce un handshake limpio con un intercambio de claves más débil de lo esperado. Loguea la curva negociada explícitamente. No asumas que HTTPS funcionando significa que se usó el intercambio de claves correcto.

← Volver al Blog