Apuntes · Capítulo 13Transporte y aplicaciones

TLS, QUIC y CDNs

TLS, QUIC y CDNs

Las semanas anteriores armaron una pila que funciona: TCP entrega bytes en orden, el control de congestión reparte la red y HTTP pide páginas por nombre. Falta decir lo incómodo. Todo eso viaja a la vista. Cada router del camino puede leer la petición, cambiar la respuesta o hacerse pasar por el servidor, y ni TCP ni HTTP tienen forma de notarlo. Tampoco está resuelto otro problema: una página sigue tardando varias idas y vueltas antes del primer byte útil, y una sola pérdida en TCP detiene todos los streams de HTTP/2.

Esta semana responde a esas dos cuestiones, y agrega una tercera. Primero, cómo se protege una conexión entre dos extremos que nunca se vieron, sobre una red en la que no confían. La respuesta es TLS. Segundo, cómo se cambia el transporte de Internet si no se puede cambiar TCP. La respuesta es QUIC. Tercero, cómo se reduce la distancia cuando la luz ya no va a ir más rápido: las CDN y el streaming adaptativo.

En la Sesión A vemos lo mínimo de criptografía para entender TLS, sin demostraciones y con números que caben en una línea. Seguimos un handshake de TLS 1.3 real, contamos sus RTT y leemos en una captura lo que puede ver quien está en el medio, que es más de lo que se suele creer.

En la Sesión B vemos por qué QUIC corre sobre UDP, qué gana con eso y cuánto de su “cifrado” protege de verdad. Después pasamos a las CDN, con selección por DNS y por anycast en una red con BGP, y a DASH, con un cliente que decide la calidad de cada segmento mientras le cortamos el ancho de banda.

Hay tres clases de resultados en este capítulo. Las trazas de los simuladores son exactas y se repiten igual. Las capturas y los mensajes muestran una ejecución real: las claves, los puertos y los IDs cambian en cada corrida. Las mediciones dependen de la computadora y de lo que esté corriendo, y las que usan Internet dependen además de su conexión y del día: comparen tendencias.

Ruta de lectura.

Antes de clase (obligatorio, unos 15 a 20 minutos). Sesión A: Qué se quiere proteger, Criptografía simétrica, la introducción de Clave pública: RSA y Diffie-Hellman con Por qué no se usa la clave pública para todo, Hash, MAC y firma digital y Certificados y autoridades de certificación. Sesión B: Por qué no se cambia TCP y la introducción de Streams independientes.

En clase. Sesión A: RSA en una cuenta, Diffie-Hellman en una cuenta, TLS 1.3, paso a paso y Lo que ve quien está en el medio. Sesión B: Los streams, en una captura real, El handshake integrado, 0-RTT y su riesgo de repetición, Migración de conexión, CDN: acercar el contenido y Streaming adaptativo con DASH.

Después de clase. Repasen con el Resumen y los Ejercicios, y preparen Laboratorio: un handshake TLS y HTTP/2 frente a HTTP/3. Opcional: Descifrar la captura, con permiso, El cifrado que no protege y Un cliente real. Para profundizar, las lecturas del libro y las gratuitas de la semana:

Este capítulo acompaña a Kurose y Ross, Computer Networking: A Top-Down Approach (9.ª ed.). Para la Sesión A, las secciones 8.2 Principles of Cryptography, 8.3 Message Integrity and Digital Signatures y 8.6 Securing TCP and HTTP Connections: TLS, y el capítulo TLS de CS168. Complementaria: RFC 8446, TLS 1.3, secciones 1 y 2. Para la Sesión B, las secciones 2.2.7 HTTP/3 and QUIC, 2.5 Video Streaming and Content Distribution Networks y 3.8 Evolution of Transport-Layer Functionality, y el RFC 9000, QUIC, secciones 1 y 2. Complementarias: RFC 9001, QUIC con TLS, y RFC 9114, HTTP/3. El código de la semana está en code/week-13/.

Qué se quiere proteger

Antes de hablar de algoritmos hay que decir contra quién nos defendemos. En esta red, el atacante está en el camino: es un router, un access point en una cafetería o alguien que controla un enlace. Puede leer todo lo que pasa, borrar paquetes, cambiarlos, repetirlos e inventar otros. No puede romper las matemáticas de la criptografía ni entrar a las computadoras de los extremos.

Contra ese atacante se quieren tres propiedades, y conviene no mezclarlas:

Propiedad Pregunta que responde Falla típica sin ella
Confidencialidad ¿Solo el destinatario puede leerlo? Alguien lee su contraseña en el Wi-Fi
Integridad ¿Llegó tal como salió? Un proxy inyecta publicidad en la página
Autenticación ¿Hablo con quien creo? Un servidor falso recibe su contraseña

Las tres son necesarias a la vez. Un canal perfectamente cifrado con el atacante, porque no se autenticó al otro extremo, no protege nada. Y cifrar no da integridad, como veremos con un ejemplo en la siguiente sección.

Idea clave. TLS no hace que la red sea confiable. Hace que la red no importe: los extremos detectan cualquier cambio y nadie más puede leer el contenido. Lo que no impide es que el atacante corte la conexión. Contra un DoS, TLS no ayuda.

Criptografía simétrica

Definición. En el cifrado (encryption) simétrico, o de clave simétrica (symmetric key), las dos partes comparten una misma clave K. Cifrar es aplicar E(K, texto), y descifrar es aplicar D(K, cifrado). Quien no tiene K no debería poder sacar nada del texto cifrado, más allá de su tamaño.

El algoritmo más usado es AES, que cifra bloques de 16 bytes, y hoy también ChaCha20, que es rápido en procesadores sin instrucciones especiales para AES. Son muy rápidos: en la máquina donde se preparó este capítulo, con openssl speed, AES-128-GCM procesa unos 3 GB por segundo:

$ openssl speed -seconds 1 -evp aes-128-gcm
type             16 bytes     64 bytes    256 bytes   1024 bytes   8192 bytes  16384 bytes
AES-128-GCM      37154.73k   167274.64k   534014.49k  1550361.60k  2895376.17k  3129831.62k

Las cifras son miles de bytes por segundo, y dependen de cada computadora.

El problema es obvio: ¿cómo se ponen de acuerdo en K dos máquinas que nunca se vieron, si todo lo que se dicen pasa por el atacante? A eso volvemos enseguida.

Cifrar no es proteger la integridad

Muchos modos de cifrado funcionan así: generan una secuencia de bytes que parece azar a partir de la clave (AES en modo contador, CTR), y hacen un XOR con el mensaje. Un atacante que no conoce la clave no puede leer. Pero sí puede invertir bits: un XOR sobre el cifrado se vuelve el mismo XOR sobre el texto. ctr_flip.py lo hace con el comando openssl enc. Conoce el formato del mensaje, y cambia el primer dígito del monto sin saber la clave:

$ python3 ctr_flip.py
mensaje:          pagar 0100 soles a Ana
cifrado:          2e79b69f843d384ff0931ea45bcbf4fc3aa39f9df994
cifrado alterado: 2e79b69f843d314ff0931ea45bcbf4fc3aa39f9df994
se descifra como: pagar 9100 soles a Ana
MAC del original sobre el alterado: NO coincide, se descarta

Un solo byte cambió en el cifrado (38 por 31), y el destinatario descifra otro monto sin ningún error. La última línea adelanta la solución: un código de autenticación, que veremos más abajo, sí lo detecta.

Por eso TLS 1.3 solo usa cifrado autenticado (AEAD): AES-GCM o ChaCha20-Poly1305. Cifran y agregan una etiqueta en la misma operación, y si la etiqueta no coincide, se descarta el mensaje sin entregarlo.

Error común. Creer que si está cifrado, nadie lo pudo cambiar. El atacante de arriba no sabe qué dice el mensaje, y aun así eligió exactamente qué cambiar. La integridad necesita su propio mecanismo.

Clave pública: RSA y Diffie-Hellman

Definición. En la criptografía de clave pública (public key), cada parte tiene dos claves relacionadas: una pública, que le da a cualquiera, y una privada, que nunca sale de su máquina. Lo que se hace con una se deshace con la otra, y conocer la pública no permite calcular la privada en un tiempo razonable.

Esto resuelve el problema del acuerdo, y da algo más: una forma de probar quién es uno sin decir el secreto. Veamos los dos algoritmos con números de juguete. crypto_toys.py usa números diminutos a propósito: no protege nada, solo deja seguir las cuentas.

RSA en una cuenta

Se eligen dos primos p y q. El módulo es n = p × q. Se elige e, y se calcula d de modo que e × d deje resto 1 al dividirlo entre (p-1)(q-1). La clave pública es (n, e), y la privada es d. Cifrar y descifrar son la misma operación, una potencia módulo n, con distinto exponente:

== RSA con p=61, q=53, e=17
n=3233 e=17 d=2753
cifrar con la pública:  65^17 mod 3233 = 2790
descifrar con la privada: 2790^2753 mod 3233 = 65
firmar con la privada:  65^2753 mod 3233 = 588
verificar con la pública: 588^17 mod 3233 = 65

La seguridad viene de que, dado n, encontrar p y q es muy difícil si n tiene miles de bits. Con n = 3233 lo hacen ustedes a mano. Fíjense en que las dos últimas líneas invierten el orden. Si uno aplica su clave privada y cualquiera puede deshacerlo con la pública, eso prueba quién lo hizo. Esa es la idea de la firma digital (digital signature).

El RSA real no se usa así, sin más: un mensaje siempre se transforma antes, con un relleno aleatorio (padding), porque el RSA de libro es determinista y maleable. Eso queda para un curso de criptografía.

Diffie-Hellman en una cuenta

Diffie-Hellman es la idea más sorprendente de esta semana: dos personas llegan a un mismo secreto hablando en público, sin haber compartido nada antes. Se acuerda en público un primo p y un generador g. Cada lado elige un número secreto, y publica g elevado a ese número, módulo p. Cada uno eleva lo que recibió a su propio secreto:

== Diffie-Hellman con p=2087, g=5
Ana elige a=731 y publica A = g^a mod p = 114
Beto elige b=1289 y publica B = g^b mod p = 2029
Ana calcula B^a mod p = 1778
Beto calcula A^b mod p = 1778
quien solo vio A y B, probando exponentes, halla a=731

Los dos llegan a 1778 porque (g^b)^a = (g^a)^b. Quien solo vio A y B debe sacar un exponente de una potencia modular, lo que se llama el logaritmo discreto. La última línea muestra que con p = 2087 se resuelve probando. Con un p de miles de bits, o con curvas elípticas (como X25519, la que usa casi todo Internet hoy) no se conoce ninguna forma práctica de hacerlo.

Una advertencia que importa más que las cuentas: Diffie-Hellman solo no autentica. Un man-in-the-middle puede hacer un intercambio con Ana y otro con Beto, y leer todo, sin romper nada. Le falta saber con quién se habla, y de eso se encargan las firmas.

Por qué no se usa la clave pública para todo

Porque es muchísimo más lenta. La misma corrida de openssl speed:

$ openssl speed -seconds 1 rsa2048
                               keygen     signs    verify keygens/s    sign/s  verify/s
                    rsa2048 0.055556s 0.000820s 0.000019s      18.0    1219.6   51373.2
$ openssl speed -seconds 1 ecdhp256
                              op      op/s
 256 bits ecdh (nistp256)   0.0001s  18781.8

Una firma RSA de 2048 bits procesa 256 bytes. A 1220 firmas por segundo son unos 0,3 MB/s, contra 3 GB/s de AES-GCM: una diferencia de unas 10 000 veces en esta máquina. La cuenta es solo para dar una idea, porque nadie cifra datos con RSA.

Por eso todo protocolo real es híbrido: la clave pública se usa pocas veces al inicio, para autenticar y acordar una clave; todos los datos van con cifrado simétrico.

Herramienta Para qué se usa en TLS 1.3 Para qué no
Diffie-Hellman efímero Acordar el secreto de la conexión No autentica a nadie
Firma (RSA, ECDSA, Ed25519) Probar quién es el servidor No cifra datos
AEAD (AES-GCM, ChaCha20-Poly1305) Proteger todos los datos No sirve sin una clave acordada

Hash, MAC y firma digital

Tres herramientas parecidas, que se confunden con frecuencia.

Definición. Una función hash criptográfica convierte cualquier cantidad de bytes en un resumen de tamaño fijo (32 bytes en SHA-256). Es fácil de calcular, pero es inviable encontrar un mensaje con un resumen dado, o dos mensajes con el mismo resumen. Un cambio mínimo en la entrada da un resumen que no se parece en nada:

== Hash: un carácter cambia cerca de la mitad de los bits
sha256('pagar 100 soles') = f5e3942bce4a5a603bf1d93471cd0d72c06c4ea5f18100c619d31e82759ddf27
sha256('pagar 900 soles') = 1a58b501c38fec2af33d38c3046340357979f7b6c552b4606f712960af0ccb35
bits distintos: 130 de 256

Un hash no tiene clave, así que no autentica nada: si el atacante cambia el mensaje, recalcula el hash, y listo.

Definición. Un MAC (message authentication code) es un resumen que depende además de una clave compartida. La construcción clásica es HMAC. Quien no tiene la clave no puede producir un MAC válido:

== MAC: el hash solo no autentica, porque cualquiera lo recalcula
hmac(clave, 'pagar 100 soles') = 00e99ba858f25a3cc2a1d86805d54ad49677e7e8cf175a69ae8e78e496060809
hmac(otra,  'pagar 100 soles') = 91749f1af1c82915c9b254f8203d6abc88cba103ac8d66cb4e2abe1e169f25f5

Ojo: “MAC” aquí nada tiene que ver con la dirección MAC de Ethernet. El nombre es una coincidencia.

Definición. Una firma digital es un resumen hecho con una clave privada, que cualquiera verifica con la pública. Con openssl y una clave Ed25519:

$ openssl genpkey -algorithm ed25519 -out firma.key
$ openssl pkey -in firma.key -pubout -out firma.pub
$ printf "pagar 100 soles\n" > msg.txt
$ openssl pkeyutl -sign -inkey firma.key -rawin -in msg.txt -out msg.sig
$ wc -c msg.sig
64 msg.sig
$ openssl pkeyutl -verify -pubin -inkey firma.pub -rawin -in msg.txt -sigfile msg.sig
Signature Verified Successfully
$ printf "pagar 900 soles\n" > msg.txt
$ openssl pkeyutl -verify -pubin -inkey firma.pub -rawin -in msg.txt -sigfile msg.sig
20807B83FFFF0000:error:030000EA:digital envelope routines:EVP_DigestVerify:provider signature failure:../crypto/evp/m_sigver.c:781:ED25519 digest_verify:
Signature Verification Failure
¿Tiene clave? ¿Quién puede crearlo? ¿Quién puede verificarlo?
Hash No Cualquiera Cualquiera
MAC Compartida Quien tenga la clave Quien tenga la clave
Firma Privada y pública Solo el owner de la privada Cualquiera

La última fila es la importante. Con un MAC, cualquiera de los dos pudo haber creado el mensaje, así que no le prueba nada a un tercero. Con una firma, solo pudo ser uno. Y por eso sirve para lo siguiente.

Certificados y autoridades de certificación

La firma nos deja un problema: para verificar la firma del servidor necesito su clave pública. Si esa clave me llega por la red, el atacante puede darme la suya. ¿Cómo sé que la clave es de verdad del owner del nombre?

Definición. Un certificado (certificate) es un documento firmado que dice: “esta clave pública pertenece a este nombre, durante estas fechas”. Lo firma una autoridad de certificación (CA), una entidad que, antes de firmar, comprueba que quien lo pide controla el nombre.

Eso no resuelve el problema, lo mueve: ahora necesito la clave de la CA. La diferencia es que esa ya está en mi computadora. Los sistemas operativos y los navegadores traen una lista de CA raíz en las que confían, instalada de fábrica. Esa lista es el verdadero punto de partida de la confianza en la web.

La clase tiene su propia CA. pki.sh la crea y firma varios certificados, para el nombre de prueba demo.redes.test:

$ make
$ cd build/pki
$ openssl x509 -in good.crt -noout -subject -issuer -dates -ext subjectAltName,basicConstraints
subject=CN=demo.redes.test
issuer=O=Curso de Redes, CN=CA de la clase
notBefore=Nov  2 14:45:13 2026 GMT
notAfter=Dec  2 14:45:13 2026 GMT
X509v3 Subject Alternative Name: 
    DNS:demo.redes.test
X509v3 Basic Constraints: 
    CA:FALSE
$ openssl verify -CAfile ca.crt good.crt
good.crt: OK

Lean los campos con cuidado:

  • subject e issuer: de quién es y quién lo firmó.
  • subjectAltName: los nombres que vale. Es este campo, y no el CN de subject, el que los clientes actuales comparan con el nombre que pidió el usuario.
  • CA:FALSE: esta clave no puede firmar otros certificados. Sin esa restricción, cualquiera que tuviese un certificado podría emitirlos para el nombre que quiera.
  • Fechas: un certificado vence. Cuanto menos dure, menos vale uno robado.

En Internet hay un paso más: la raíz casi nunca firma certificados de servidor directamente. Se guarda sin conexión, y firma a CA intermedias, que son las que firman a diario. El servidor manda su certificado y las intermedias, y el cliente arma la cadena hasta una raíz que ya conoce. Así se ve un sitio real, con openssl s_client:

$ python3 internet.py chain
 0 s:CN=www.cloudflare.com
   i:C=US, O=Let's Encrypt, CN=YE1
   a:PKEY: EC, (prime256v1); sigalg: ecdsa-with-SHA384
   v:NotBefore: Nov  1 07:17:46 2026 GMT; NotAfter: Jan 30 07:17:45 2027 GMT
 1 s:C=US, O=Let's Encrypt, CN=YE1
   i:C=US, O=ISRG, CN=Root YE
   a:PKEY: EC, (secp384r1); sigalg: ecdsa-with-SHA384
   v:NotBefore: Sep  3 00:00:00 2025 GMT; NotAfter: Sep  2 23:59:59 2028 GMT
 2 s:C=US, O=ISRG, CN=Root YE
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X2
   a:PKEY: EC, (secp384r1); sigalg: ecdsa-with-SHA384
   v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep  2 23:59:59 2032 GMT
 3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
   a:PKEY: EC, (secp384r1); sigalg: sha256WithRSAEncryption
   v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep  2 23:59:59 2032 GMT
Verification: OK
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
Protocol: TLSv1.3

Cada i: (issuer) es el s: (subject) de la línea siguiente. El certificado 0 dura 90 días. Lo que ustedes vean cambiará con el tiempo: es un dato vivo, no algo para memorizar.

Idea clave. Un certificado es público. Cualquiera puede bajar el de su banco y presentarlo. Lo que un atacante no puede hacer es probar que tiene la clave privada que le corresponde. Por eso el certificado por sí solo no autentica: la prueba es la firma que el servidor hace durante el handshake, como vamos a ver.

En la práctica. El punto débil del sistema es que cualquier CA de la lista puede firmar para cualquier nombre. Ha pasado que una CA comprometida emitió certificados falsos para sitios grandes. Como defensa, hoy los certificados se publican en registros públicos de solo agregar (Certificate Transparency), donde el owner de un nombre puede ver si alguien emitió uno a su nombre.

TLS 1.3, paso a paso

Ya están todas las piezas. TLS (Transport Layer Security) corre encima de TCP, y le da a la aplicación un stream de bytes protegido. El handshake hace tres cosas a la vez: acuerda las claves, autentica al servidor y deja listo el cifrado. Y las hace en una sola ida y vuelta:

Animación del handshake de TLS 1.3 entre cliente y servidor, con el router mb en el medio, sin claves. El cliente manda ClientHello en claro con versiones, cipher suites, SNI, ALPN y key_share; mb lee el SNI demo.redes.test, y el servidor ya puede calcular el secreto común. El servidor manda ServerHello en claro, con su key_share, y el cliente calcula el mismo secreto. Luego, cifrados, EncryptedExtensions, Certificate, CertificateVerify y Finished: mb solo lee Application Data. Una ida y vuelta después, el cliente manda Finished y la petición, cifrados, y al final llega la respuestaAnimación del handshake de TLS 1.3 entre cliente y servidor, con el router mb en el medio, sin claves. El cliente manda ClientHello en claro con versiones, cipher suites, SNI, ALPN y key_share; mb lee el SNI demo.redes.test, y el servidor ya puede calcular el secreto común. El servidor manda ServerHello en claro, con su key_share, y el cliente calcula el mismo secreto. Luego, cifrados, EncryptedExtensions, Certificate, CertificateVerify y Finished: mb solo lee Application Data. Una ida y vuelta después, el cliente manda Finished y la petición, cifrados, y al final llega la respuesta
Un RTT para las claves, la autenticación y el cifrado, y desde el segundo vuelo casi todo va oculto. Los números de paquete son de la captura que vemos más abajo.

Las llaves {} marcan lo que va cifrado. Mensaje por mensaje:

  • ClientHello. Las versiones que el cliente entiende, las cipher suites, el nombre del servidor (SNI), los protocolos de aplicación que quiere usar (ALPN, por ejemplo h2) y, lo importante, su mitad de un Diffie-Hellman adivinando el grupo que el servidor va a aceptar. Esa adivinanza es lo que ahorra una ida y vuelta frente a TLS 1.2.
  • ServerHello. La versión y la suite elegidas, y la mitad de Diffie-Hellman del servidor. Con eso, los dos ya pueden calcular el mismo secreto, y de él derivan las claves del handshake. Todo lo que sigue va cifrado.
  • Certificate. La cadena de certificados del servidor. A diferencia de TLS 1.2, ya no se ve desde afuera.
  • CertificateVerify. La pieza que importa: una firma, con la clave privada del certificado, sobre el resumen de todos los mensajes anteriores del handshake. Prueba dos cosas: que el servidor tiene la clave privada, y que es él quien mandó ese Diffie-Hellman. Es lo que detiene al man-in-the-middle de la sección anterior.
  • Finished. Un MAC sobre todo el handshake, de cada lado, con claves derivadas del secreto. Si alguien cambió un byte de un mensaje, por ejemplo para quitar las mejores cipher suites de la oferta, el MAC no coincide y la conexión se cae.

Dos consecuencias conviene ver con calma:

El cliente puede mandar datos justo después de su Finished, sin esperar otra respuesta. Contando el handshake de TCP:

Protocolo RTT antes de que el cliente reciba el primer byte de respuesta
TCP solo 2 (handshake de TCP, petición)
TCP + TLS 1.3 3 (TCP, TLS, petición)
TCP + TLS 1.2 4 (TCP, dos de TLS, petición)

Normalmente, el servidor no autentica al cliente. El cliente sabe con quién habla, pero el servidor no. Para eso están la contraseña o la cookie, que ahora viajan protegidas. TLS permite pedir certificado al cliente, y es común entre servicios de una misma empresa, pero no en la web.

Contar los RTT

Vamos a comprobarlo en una red con un RTT conocido. net/net.clab.yml tiene un cliente c, un servidor s y un router mb en el medio, con 20 ms de retardo en cada una de sus dos interfaces. Con 20 ms de ida y 20 de vuelta, son 40 ms de RTT:

 c 10.213.1.10 ---- eth1 mb eth2 ---- 10.213.2.10 s
                  20 ms por interfaz

Del lado del servidor corre openssl s_server -www, que responde a cualquier GET con una página de estado. Desde c, curl mide cada fase, y conectamos 7 veces para sacar la mediana:

$ make up
$ python3 scenarios.py rtts
RTT de la red: 40 ms (retardo de netem en mb). Medianas de 7 conexiones, en ms.
           TCP listo  TLS listo  primer byte  RTT del TLS  RTT al byte
TLS 1.3         44.1       88.7        138.0          1.1          3.5
TLS 1.2         43.2      129.1        170.9          2.1          4.3

La columna “RTT del TLS” es (TLS listo - TCP listo) / 40. Es lo que la teoría anticipa: uno contra dos. Con TLS 1.2, el cliente no puede pedir hasta completar dos idas y vueltas después de TCP. La última columna es el primer byte dividido entre 40: 3,5 y 4,3, es decir, los 3 y 4 RTT de la tabla más lo que tarda s_server en armar su página y lo que cuesta correr todo en contenedores. Estos valores son de una corrida, y varían un poco.

Error común. Creer que TLS 1.3 es más rápido porque cifra mejor. El cifrado de los datos cuesta casi lo mismo. Lo que cambió es el diseño del handshake: el cliente manda su key_share en el primer mensaje, en vez de negociar y luego mandarlo.

Forward secrecy

Imaginen a alguien que graba todo el tráfico cifrado de un servidor durante años, sin poder leerlo. Un día, roba la clave privada del certificado. ¿Puede descifrar lo que grabó?

Con el intercambio RSA de TLS 1.2, sí: el cliente elegía un secreto y lo mandaba cifrado con la clave pública del servidor. Con la clave privada se sacan todos los secretos de todas las conexiones viejas. Una sola clave robada abre años de tráfico. (TLS 1.2 también permitía Diffie-Hellman efímero, pero era opcional.)

Definición. Hay forward secrecy si robar las claves de larga duración no permite descifrar sesiones pasadas.

TLS 1.3 la da porque ya no existe el intercambio RSA. Cada conexión hace un Diffie-Hellman efímero: las dos mitades secretas se crean para esa conexión, y se borran al calcular la clave. La clave privada del certificado solo firma, y nunca interviene en el secreto. Quien la robe puede hacerse pasar por el servidor a partir de ese día, lo cual es grave, pero no puede abrir lo grabado: los números que hacían falta ya no existen en ninguna parte.

Idea clave. Autenticar y acordar son dos trabajos distintos, y TLS 1.3 los separa. La clave de larga duración firma, y la efímera acuerda. Separarlas es lo que hace que un robo no se propague hacia atrás.

Los tickets de reanudación, que veremos con 0-RTT, relajan esto un poco: los datos de 0-RTT no tienen forward secrecy frente al robo de la clave con la que el servidor protege sus tickets (RFC 8446, sec. 2.3).

Lo que ve quien está en el medio

Ahora capturemos ese handshake desde mb, el lugar donde estaría un atacante o el firewall de una empresa. Sin ninguna clave:

$ python3 scenarios.py observer
Lo que ve mb, sin claves:
4	10.213.1.10	
5	10.213.1.10	Client Hello (SNI=demo.redes.test)
8	10.213.2.10	Server Hello, Change Cipher Spec, Application Data
9	10.213.2.10	Application Data, Application Data, Application Data
12	10.213.1.10	Change Cipher Spec, Application Data
13	10.213.1.10	Application Data
14	10.213.2.10	Application Data
15	10.213.2.10	Application Data
16	10.213.2.10	
19	10.213.2.10	Application Data
20	10.213.2.10	Application Data
23	10.213.1.10	Application Data

El nombre del servidor, leído sin claves:
demo.redes.test

Mensajes de certificado que se pueden leer: 0

Las columnas son número de paquete, origen e interpretación de tshark. Vamos línea por línea:

  • Paquetes 4 y 5: ClientHello, en claro, partido en dos segmentos TCP. Mide 1570 bytes, más que un MSS: en seguida vemos por qué.
  • Paquete 8: ServerHello, en claro. Tiene que ser así, porque lleva la mitad de Diffie-Hellman. Lo que aparece como “Application Data” después es el resto del vuelo. Es un disfraz: los registros cifrados de TLS 1.3 se declaran todos del tipo Application Data, y el tipo real va adentro, cifrado.
  • Paquete 9: ese “Application Data” incluye el certificado. Nadie en el camino sabe a qué servidor le pertenece el certificado que se presentó.
  • Change Cipher Spec: no hace nada. TLS 1.3 lo manda solo para parecerse a una conexión de TLS 1.2, porque algunos middleboxes viejos cortaban lo que no reconocían (RFC 8446, apéndice D.4). Es un rastro de un problema que resolverá QUIC.

Leer el ClientHello sin saber TLS

Esa última línea es el corazón del error común de esta sesión. sni_peek.c es un programa en C de unas 250 líneas que abre un socket TCP, lee el primer registro y recorre sus campos. No enlaza con ninguna biblioteca de TLS, no tiene clave ni certificado, y nunca responde. Ve lo mismo que cualquier equipo del camino:

$ python3 scenarios.py peek
escuchando en el puerto 443
conexión de 10.213.1.10
  registro: tipo 22 (handshake), versión 3.1, 1570 bytes
  cipher suites ofrecidas: 30
  SNI: demo.redes.test
  ALPN: h2 http/1.1
  versiones: TLS1.3 TLS1.2
  key_share: grupo 0x11ec (1216 bytes) grupo 0x001d (32 bytes)

Fíjense en tres cosas:

  • versión 3.1 es lo que declara el registro, aunque el cliente ofrezca TLS 1.3. La negociación real va en la extensión supported_versions. Otra vez: si el campo original dijera la versión nueva, algunos middleboxes cortarían la conexión.
  • El grupo 0x001d es X25519, con los 32 bytes que esperábamos. El grupo 0x11ec es X25519MLKEM768, un híbrido con un esquema resistente a computadoras cuánticas, cuya clave ocupa 1216 bytes. Por eso el ClientHello ya no entra en un solo paquete. La razón de agregarlo es que alguien podría grabar tráfico hoy y descifrarlo cuando exista una computadora así.
  • El parser desconfía de todo. Cada campo de largo viene del otro extremo, y puede mentir. Por eso el código lee a través de un cursor que revisa los límites en cada paso, y escapa los bytes no imprimibles antes de mostrarlos. Las pruebas le mandan recortes del mensaje en doce puntos, y largos falsos. Es el mismo cuidado que van a necesitar en P5 con las peticiones mal formadas.

Veamos el cursor, porque es la idea que se lleven al leer cualquier protocolo binario:

struct cursor {
    const uint8_t *data;
    size_t left;
};

static int read_u16(struct cursor *c, unsigned *value) {
    if (c->left < 2) return -1;
    *value = ((unsigned)c->data[0] << 8) | c->data[1];
    c->data += 2;
    c->left -= 2;
    return 0;
}

Error común. Creer que HTTPS oculta todo. Quien observa la red todavía ve: las direcciones IP de los dos extremos, los tiempos y tamaños de cada mensaje (suficiente, a veces, para adivinar qué video se ve o qué página se abrió), y normalmente el nombre del servidor en el SNI. Además, si la consulta de DNS no va cifrada, también la ve. Lo que queda oculto es la ruta, los headers, las cookies y el contenido: /banco/transferir se oculta, www.banco.pe no.

El SNI no se puede cifrar con las claves de la sesión por un problema de orden: el servidor necesita saber qué nombre le piden para elegir qué certificado presentar, y eso pasa antes de que exista ninguna clave. Hay una extensión que resuelve esto, Encrypted Client Hello, que cifra el ClientHello interno con una clave publicada en DNS. Su adopción todavía es parcial, y el curl de nuestro contenedor no la usó en estas capturas.

En la práctica. Muchos firewalls filtran por SNI: no pueden ver la página, pero sí el nombre. Y algunas empresas instalan su propia CA en las laptops de los empleados para hacer un man-in-the-middle autorizado, y abrir el contenido. Lo que protege al usuario en ese caso no es la matemática: ya confía en quien lo intercepta, porque su computadora trae esa CA.

Descifrar la captura, con permiso

Los extremos sí tienen las claves. Si curl encuentra la variable SSLKEYLOGFILE, escribe los secretos de cada conexión en ese archivo. tshark y Wireshark los pueden leer, y abrir la captura:

$ python3 scenarios.py keylog
Etiquetas del archivo de claves:
CLIENT_HANDSHAKE_TRAFFIC_SECRET
CLIENT_TRAFFIC_SECRET_0
EXPORTER_SECRET
SERVER_HANDSHAKE_TRAFFIC_SECRET
SERVER_TRAFFIC_SECRET_0

La misma captura, ahora con las claves:
4	10.213.1.10	
5	10.213.1.10	Client Hello (SNI=demo.redes.test)
8	10.213.2.10	Server Hello, Change Cipher Spec, Encrypted Extensions
9	10.213.2.10	Certificate, Certificate Verify, Finished
12	10.213.1.10	Change Cipher Spec, Finished
13	10.213.1.10	GET / HTTP/1.1 
14	10.213.2.10	New Session Ticket
15	10.213.2.10	New Session Ticket
16	10.213.2.10	
19	10.213.2.10	[TLS segment of a reassembled PDU]
23	10.213.1.10	Alert (Level: Warning, Description: Close Notify)

La petición, en claro:
GET	/	demo.redes.test

Comparen con la captura anterior, línea por línea. Es el diagrama de arriba, en paquetes reales. Las etiquetas también cuentan algo: hay claves distintas para el handshake y para los datos, y para cada sentido. Los New Session Ticket son para reanudar más tarde.

Ese archivo es tan sensible como una contraseña: quien lo tenga junto con una captura puede leerlo todo. Se usa para depurar, y nunca en producción.

Qué protege el error de curl

Ahora le presentamos a curl cuatro certificados, todos para el mismo servidor, en la misma dirección IP. Solo cambia lo que el servidor le muestra:

$ python3 scenarios.py certs
certificado    salida  mensaje de curl
good                0  bien
selfsigned         60  curl: (60) SSL certificate problem: self-signed certificate
wrongname          60  curl: (60) SSL: no alternative certificate subject name matches target hostname 'demo.redes.test'
expired            60  curl: (60) SSL certificate problem: certificate has expired

Con la CA del sistema, sin la CA de la clase:
good               60  curl: (60) SSL certificate problem: unable to get local issuer certificate

Cada fila es un ataque que se detuvo, o un descuido que se detectó:

  • selfsigned. Cualquiera puede generar uno con cualquier nombre, en un segundo. Si se aceptara, la autenticación no valdría nada. Es exactamente lo que un man-in-the-middle presentaría.
  • wrongname. Firmado por una CA de confianza, y válido… para otro sitio. Sin esta comprobación, quien compre un certificado para otro.pe podría hacerse pasar por su banco.
  • expired. Vencido. Limita el daño de una clave robada, y es también la causa de muchas caídas reales cuando alguien olvida renovar.
  • Sin la CA. El mismo good, ahora sin --cacert. Nada ha cambiado en el servidor: lo que falta es la confianza del cliente. Es el punto del párrafo sobre raíces.

Error común. Resolver esto con curl -k o desactivando la validación “porque es solo una prueba”. Con eso se conserva el cifrado y se pierde la autenticación: hablan en secreto con quien sea. En un script que terminó en producción, equivale a no tener TLS.

Por qué no se cambia TCP

Empieza la Sesión B. Ya saben que TCP tiene problemas de diseño para la web actual: un handshake que suma un RTT antes de TLS, un solo orden de bytes para todo (el bloqueo que vieron con HTTP/2) y conexiones atadas a cuatro campos. Las soluciones se conocen desde hace años. ¿Por qué no modificar TCP?

Por dos razones que no tienen que ver con TCP, sino con lo que lo rodea:

  • El kernel. TCP vive en el sistema operativo. Cambiarlo requiere que Microsoft, Apple, los fabricantes de celulares y cada servidor actualicen su kernel. Algunos dispositivos no se actualizan nunca. Una mejora tarda una década en llegar a la mayoría.
  • Los middleboxes. Firewalls, NAT y otros equipos miran los encabezados de TCP, y descartan o “corrigen” lo que no reconocen. Una opción nueva de TCP puede funcionar en el laboratorio y fallar al cruzar la red de un operador. Al protocolo se le fue quitando margen para evolucionar: se dice que se osificó. Lo vimos en TLS 1.3, que tuvo que disfrazarse de TLS 1.2 para pasar.

Lo que el libro llama la evolución de la funcionalidad del transporte (sección 3.8) se resume en esta pregunta: ¿dónde puede vivir un transporte nuevo, si no puede tocar ni el kernel ni lo que los middleboxes entienden?

QUIC responde: en espacio de usuario, dentro de una biblioteca que viene con la aplicación, y sobre UDP, que los middleboxes ya dejan pasar. Una actualización del navegador es una actualización del transporte. Y cifra casi todo, con lo que los middleboxes no tienen nada que osificar.

       HTTP/2 sobre TCP                     HTTP/3 sobre QUIC

   +----------------------+              +----------------------+
   |   HTTP/2 (streams)   |              |       HTTP/3         |   usuario
   +----------------------+              +----------------------+
   |      TLS 1.3         |              |  QUIC (streams,      |
   +----------------------+              |  confiabilidad,      |
   |        TCP           |  kernel      |  congestión, TLS 1.3)|
   +----------------------+              +----------------------+
   |         IP           |              |        UDP           |   kernel
   +----------------------+              +----------------------+
                                         |         IP           |
                                         +----------------------+

UDP aquí aporta casi nada, y justamente por eso sirve: los puertos para que el kernel entregue el datagrama, y un encabezado que todo middlebox ya entiende. El resto del transporte lo hace QUIC. Es como la semana 9 lo anticipaba: UDP es una base sobre la que uno construye lo que necesita.

Error común. Creer que UDP hace a QUIC poco confiable. QUIC implementa todo lo que TCP hace y UDP no: números de secuencia, ACK, retransmisión, control de flujo y control de congestión. Es un transporte confiable completo que usa UDP como sobre, igual que el rimay que ustedes están terminando en P4. Si llega a la aplicación, llega completo y en orden dentro de su stream.

Una consecuencia práctica: como algunas redes bloquean o limitan UDP, QUIC no se puede dar por hecho. El cliente descubre el soporte con un header, y normalmente sigue teniendo TCP de respaldo:

$ curl -sI --http2 https://www.cloudflare.com/cdn-cgi/trace | grep -iE "^HTTP|alt-svc"
HTTP/2 404 
alt-svc: h3=":443"; ma=86400

alt-svc dice: “también tengo HTTP/3 en el puerto 443 de UDP, y puedes recordarlo por un día”. Un HEAD a esa ruta da 404, y da igual: el header es lo que nos interesa. curl --http3 intenta los dos a la vez. --http3-only, el que usamos aquí para comparar, falla si UDP está bloqueado.

Streams independientes

QUIC tiene streams como parte del transporte. Cada uno es un flujo ordenado de bytes independiente, y cada frame STREAM dice a qué stream y en qué offset va. Un paquete perdido solo deja un hueco en el stream de los datos que llevaba.

El error común de la semana 12 (creer que HTTP/2 eliminó todo bloqueo) se ve exactamente aquí. HTTP/2 tiene streams para sí mismo, pero TCP no lo sabe: lo que ve es un solo stream de bytes. Si falta un segmento, TCP no le entrega a HTTP/2 nada de lo que vino después, aunque sea de otro stream. A eso se le llama bloqueo en la cabeza de la fila (head-of-line blocking).

Para verlo hay un simulador, no una medición: el contenedor del curso tiene un cliente de HTTP/3, pero no un servidor con el que controlar pérdidas. hol_sim.py reduce el problema a lo que importa. Tres streams se turnan, y se pierde un paquete. Lo único que cambia entre los dos modos es qué orden respeta el receptor:

$ python3 hol_sim.py
TX=10 ms, retardo=50 ms, recuperación=100 ms, se pierde el paquete 3
paq stream llega  entrega(TCP)  entrega(QUIC)
  0 A0        50           50             50
  1 B0        60           60             60
  2 C0        70           70             70
  3 A1       180          180            180  <- perdido, llega retransmitido
  4 B1        90          180             90
  5 C1       100          180            100
  6 A2       110          180            180
  7 B2       120          180            120
  8 C2       130          180            130
  9 A3       140          180            180
 10 B3       150          180            150
 11 C3       160          180            160

          TCP: esperan, máx.     QUIC: esperan, máx.
A                3    100 ms              3   100 ms
B                3     90 ms              0     0 ms
C                3     80 ms              0     0 ms

Lean las columnas del paquete 4, de otro stream. Llegó a los 90 ms. En TCP, la aplicación lo recibe a los 180, porque antes de él hay un hueco. En QUIC, a los 90.

Animación de la misma corrida en dos grupos de carriles, TCP y QUIC, con un carril por stream y el tiempo en horizontal. Los paquetes A, B y C llegan cada 10 ms desde los 50 ms. A los 80 ms falta A1. En TCP, todo lo que llega después queda retenido hasta los 180 ms, cuando llega la retransmisión: esperan 3 paquetes por stream, hasta 100, 90 y 80 ms. En QUIC, solo esperan A1, A2 y A3; los streams B y C se entregan al instante, con 0 ms de esperaAnimación de la misma corrida en dos grupos de carriles, TCP y QUIC, con un carril por stream y el tiempo en horizontal. Los paquetes A, B y C llegan cada 10 ms desde los 50 ms. A los 80 ms falta A1. En TCP, todo lo que llega después queda retenido hasta los 180 ms, cuando llega la retransmisión: esperan 3 paquetes por stream, hasta 100, 90 y 80 ms. En QUIC, solo esperan A1, A2 y A3; los streams B y C se entregan al instante, con 0 ms de espera
La misma pérdida y los mismos tiempos de llegada. La línea roja es la espera: en TCP la paga cada stream, y en QUIC solo el que perdió algo.

Con esto se responde la pregunta 4 de repaso, y conviene hacerlo con cuidado:

  • En HTTP/2: una pérdida detiene todos los streams, hasta que llegue la retransmisión.
  • En HTTP/3: detiene solo el stream que perdió algo. Los demás siguen.
  • Si hay un solo stream, no hay ninguna diferencia, y las pruebas lo comprueban. Una descarga de un archivo grande no gana nada con esto. Gana una página con muchos recursos, en una red con pérdidas.

Los streams, en una captura real

Se pueden ver en la conexión real con Cloudflare, usando las claves. Cada línea es un paquete:

$ python3 internet.py quic
Sin claves:
1	1208	Initial, DCID=d698867006b8e705, PKN: 0, CRYPTO
2	1208	Initial, DCID=d698867006b8e705, PKN: 1, CRYPTO, PADDING
3	1208	Initial, SCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d, PKN: 0, ACK, CRYPTO
4	1208	Initial, SCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d, PKN: 1, CRYPTO
5	1208	Handshake, SCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d
6	1208	Handshake, SCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d
7	1208	Handshake, SCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d
8	295	Handshake, SCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d
9	1208	Handshake, DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d
10	67	Protected Payload (KP0), DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d
11	52	Protected Payload (KP0), DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d

SNI y ALPN que tshark lee sin claves:
www.cloudflare.com	h3

Con SSLKEYLOGFILE, los paquetes que llevan streams:
10	HTTP3	Protected Payload (KP0), DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d, PKN: 0, STREAM(2), SETTINGS
11	HTTP3	Protected Payload (KP0), DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d, PKN: 1, STREAM(10)
12	HTTP3	Protected Payload (KP0), DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d, PKN: 2, STREAM(6)
13	HTTP3	Protected Payload (KP0), DCID=01f9a53171c3575aa4fd80317dc2b55aa4c2f59d, PKN: 3, STREAM(0), HEADERS: GET /cdn-cgi/trace
15	HTTP3	Protected Payload (KP0), PKN: 7, ACK, STREAM(3), SETTINGS
16	HTTP3	Protected Payload (KP0), PKN: 8, STREAM(7)
17	HTTP3	Protected Payload (KP0), PKN: 9, STREAM(11)
18	HTTP3	Protected Payload (KP0), PKN: 10, STREAM(15)[Malformed Packet]

La segunda columna, en el primer bloque, es el largo de UDP. Los números de stream no son arbitrarios. Según el RFC 9000 (sec. 2.1), los dos bits bajos dicen de qué tipo es cada uno:

Bits bajos Quién lo abre Sentido En la captura
00 Cliente Dos sentidos Stream 0: la petición GET
01 Servidor Dos sentidos
10 Cliente Uno Streams 2, 6 y 10: control y QPACK (compresión de headers)
11 Servidor Uno Streams 3, 7, 11 y 15

Cada petición de HTTP/3 usa su propio stream de dos sentidos, y por eso la siguiente iría en el 4. El stream 15 aparece marcado como [Malformed Packet] por tshark: no lo investigamos, y no afecta a la descarga, que terminó bien. Los números de paquete también merecen atención: el cliente usó el 0 y el 1 en sus Initial, y vuelve a usar el 0 en su primer paquete de datos, porque cada nivel lleva su propio espacio de números. Y fíjense en el ID: el cliente empieza con un DCID elegido por él al azar, y en cuanto el servidor anuncia el suyo (SCID), pasa a usar ese.

Una diferencia con TCP que vale la pena: en QUIC un paquete retransmitido lleva un número de paquete nuevo. Lo que se repite son los datos del frame, y no el número. Por eso desaparece la ambigüedad del RTT de un segmento retransmitido, que vieron con TCP en la semana 10: cada ACK dice sin dudas de qué transmisión habla.

El handshake integrado

En TCP con TLS, se hacen dos handshakes en fila, porque son dos protocolos en capas. En QUIC, el mismo vuelo establece el transporte y la seguridad: los mensajes de TLS 1.3 (ClientHello, etc.) viajan en frames CRYPTO de QUIC. TLS no cifra los paquetes por su cuenta, como haría sobre TCP: entrega los secretos y QUIC protege sus propios paquetes (RFC 9001).

En Internet se ve con claridad. El mismo recurso, en 11 conexiones nuevas, desde el contenedor del curso. Es una medición de un día, contra un sitio concreto:

$ python3 internet.py h2h3
www.cloudflare.com/cdn-cgi/trace: medianas de 11 conexiones nuevas, en ms
         versión    DNS  conexión  TLS listo  primer byte   total
HTTP/2         2    2.3      38.3       79.6        118.5   118.6
HTTP/3         3    1.9      46.4       46.9         86.8    86.9
Dos diagramas de tiempo a la misma escala, con la altura proporcional a las medianas. A la izquierda, TCP y TLS 1.3: SYN y SYN-ACK hasta los 38,3 ms; ACK con ClientHello y ServerHello hasta los 79,6 ms; Finished con la petición y la respuesta hasta el primer byte, a los 118,5 ms. A la derecha, QUIC: Initial con ClientHello, e Initial con ServerHello más Handshake, con la conexión lista a los 46,4 ms y TLS a los 46,9 ms; luego Handshake con Finished y la petición, y la respuesta, con el primer byte a los 86,8 msDos diagramas de tiempo a la misma escala, con la altura proporcional a las medianas. A la izquierda, TCP y TLS 1.3: SYN y SYN-ACK hasta los 38,3 ms; ACK con ClientHello y ServerHello hasta los 79,6 ms; Finished con la petición y la respuesta hasta el primer byte, a los 118,5 ms. A la derecha, QUIC: Initial con ClientHello, e Initial con ServerHello más Handshake, con la conexión lista a los 46,4 ms y TLS a los 46,9 ms; luego Handshake con Finished y la petición, y la respuesta, con el primer byte a los 86,8 ms
Dos handshakes en fila contra uno solo, con las medianas de arriba. La diferencia es un RTT, y sale completa de haber juntado el transporte con la seguridad.
  • HTTP/2: con un RTT cercano a 40 ms, cada fase suma un RTT: 38, 80, 118. Son los 3 RTT de la tabla. Medimos también desde la laptop, sin contenedor, y el resultado es el mismo. No es efecto del proxy de Docker Desktop.
  • HTTP/3: la “conexión” y el “TLS listo” son casi iguales, porque son el mismo handshake. Por eso no tiene sentido comparar el 38,3 con el 46,4: el segundo incluye a TLS. El primer byte llega en 2 RTT más algo de trabajo: 87 ms.

Se ahorró un RTT completo. En un celular con 100 ms de RTT, serían 100 ms por conexión nueva. Ese es el beneficio, y es por el handshake, no por la velocidad de transferencia. En una descarga larga sobre una red estable, la diferencia se diluye.

Idea clave. Las ganancias de QUIC se concentran en tres lugares: conexiones nuevas (un RTT menos), redes con pérdidas y muchos streams (sin bloqueo entre ellos), y clientes que cambian de red (migración). En una transferencia grande, por una red buena, no hay milagro: el control de congestión es parecido, y TCP en el kernel suele gastar menos CPU.

El cifrado que no protege

La guía de esta semana dice que Wireshark “casi no puede leer nada sin las claves”. El “casi” merece toda una sección.

Miren la captura de arriba. Sin las claves, tshark muestra el SNI y el ALPN. Sin ninguna clave, de un paquete que está cifrado. ¿Cómo? Porque las claves de los paquetes Initial se calculan así:

initial_secret = HKDF-Extract(sal fija publicada en el RFC 9001, DCID del primer paquete)

El DCID viaja a la vista, y la sal está en el RFC. Cualquiera puede repetir la cuenta. quic_initial.py lo hace en unas pocas líneas, usando solo hashlib de Python y el comando openssl, con el ejemplo del RFC 9001:

$ python3 quic_initial.py --vector
DCID 8394c8f03e515708
key  1f369613dd76d5467730efcbe3b1a22d
iv   fa044b2f42a3fd3b46fb255c
hp   9f50449e04a0e810283a1e9933adedd2

Esos valores son exactamente los del apéndice A del RFC 9001, y las pruebas también abren el paquete de ejemplo completo de ese apéndice. Ahora usemos el programa con un cliente real. Con --catch, el programa abre un socket UDP y le pide a curl que intente HTTP/3 contra él. No hay servidor QUIC en ninguna parte, y no hace falta: el cliente habla primero:

$ python3 quic_initial.py --catch secreto.redes.test
datagrama 0: 1200 bytes, DCID=514057c04c133f34, número de paquete 0, bytes de CRYPTO: 1158
datagrama 1: 1200 bytes, DCID=514057c04c133f34, número de paquete 1, bytes de CRYPTO: 350
ClientHello de 1508 bytes
SNI:  secreto.redes.test
ALPN: h3

También funciona sobre una captura real. Con la de la sección anterior, contra Cloudflare:

$ python3 quic_initial.py --pcap captures/quic.pcap
datagrama 0: 1200 bytes, DCID=d698867006b8e705, número de paquete 0, bytes de CRYPTO: 1158
datagrama 1: 1200 bytes, DCID=d698867006b8e705, número de paquete 1, bytes de CRYPTO: 350
ClientHello de 1508 bytes
SNI:  www.cloudflare.com
ALPN: h3

Entonces, ¿para qué cifrar los Initial, si cualquiera los abre? Porque protege de otras cosas: un middlebox no puede osificarse sobre el formato sin tomarse el trabajo de implementar una versión de QUIC, y solo quien vio el primer paquete puede inyectar respuestas válidas. No da confidencialidad. Quien lea QUIC por primera vez debe tener esto claro.

El resto de la conexión sí tiene secreto de verdad:

Paquetes Claves ¿Quién los lee?
Initial (ClientHello, ServerHello) Del DCID y una sal pública Cualquiera en el camino
Handshake (certificado, Finished) Del Diffie-Hellman Solo los extremos
1-RTT (todo: datos, ACK, números de paquete) Del Diffie-Hellman Solo los extremos

Noten la última fila. En TCP, los números de secuencia y los ACK viajan en claro, incluso con TLS. En QUIC, los números de paquete y los ACK van protegidos. Un middlebox en el camino de una conexión QUIC no puede ver el progreso ni medir el RTT como lo hace en TCP, y mucho menos cambiar una ventana o forzar un cierre con un RST falso. Esa es la respuesta al paso 5 del laboratorio: ven poco porque está hecho a propósito, para que no puedan depender de ello, y así el protocolo pueda evolucionar.

Hay una regla más en la captura, y es la explicación de esos 1208. Todo datagrama del cliente con un Initial debe medir al menos 1200 bytes, con relleno si hace falta (PADDING, en el paquete 2). Esto no es por capricho, y está en el RFC 9000 (sec. 8.1 y 14.1): un atacante puede falsificar su dirección de origen (spoofing), y provocar que el servidor le mande muchos datos a una víctima. Por eso el servidor no puede enviar más de tres veces lo que recibió de una dirección sin validar. Obligar al cliente a mandar bastante reduce el beneficio del ataque, y comprueba de paso que el camino aguanta paquetes de ese tamaño.

0-RTT y su riesgo de repetición

Si un cliente ya habló antes con un servidor, tiene un ticket de sesión y un secreto que recordar. Con ese secreto puede cifrar datos y mandarlos en su primer paquete, junto con el ClientHello. Eso se llama 0-RTT o early data. Existe en TLS 1.3 sobre TCP, donde igual hay que pagar antes el handshake de TCP, y en QUIC, donde va desde el primer paquete: la respuesta llega en un solo RTT.

El precio es sutil. En un handshake normal, cada lado aporta datos aleatorios nuevos, así que los mensajes de una conexión no sirven en otra. En 0-RTT, el servidor todavía no aportó nada cuando recibe los datos. Por lo tanto, quien los grabe puede mandarlos otra vez, sin saber ninguna clave, y el servidor no puede distinguirlos solo por sus claves. Lo advierten los propios RFC 8446 (sec. 8) y RFC 9001 (sec. 9.2).

zero_rtt.py lo hace con TLS 1.3 real. El contenedor del curso no tiene un servidor QUIC, pero el mecanismo es el mismo, porque QUIC usa el handshake de TLS 1.3. Un cliente de openssl s_client reanuda su sesión, y manda como early data una petición que hace algo: POST /transferir?monto=100. Pasa por un relay que guarda lo que ve, sin poder leerlo, y luego lo manda otra vez, por otra conexión:

$ python3 zero_rtt.py build/pki
== protección por defecto
cliente legítimo: Early data was accepted
veces que el servidor procesó la petición: 1 antes de repetir, 1 después
== -no_anti_replay
cliente legítimo: Early data was accepted
veces que el servidor procesó la petición: 1 antes de repetir, 2 después

En la última línea, una sola transferencia del usuario se ejecutó dos veces. El atacante no conoce las claves y no puede leer la respuesta, pero no le hace falta. Su repetición no terminará nunca el handshake, porque no puede mandar un Finished válido. Sin embargo, el servidor ya procesó los datos de 0-RTT cuando llegaron.

La primera corrida muestra una defensa: s_server lleva la cuenta para que cada ticket se use una sola vez, y no procesa el early data de la repetición. Pero esa defensa no es gratis ni universal. Un sitio con muchos servidores en muchos países tendría que compartir ese estado entre todos, al instante. Muchos no lo hacen, y por eso la defensa real es otra:

Idea clave. En 0-RTT solo debe ir lo que sea seguro repetir. Un GET de una página, sí. Un pago, no. Esa decisión le toca a la aplicación, porque el transporte no sabe qué significan los datos.

Migración de conexión

Una conexión TCP se identifica por cuatro campos, y uno de ellos es la dirección IP del cliente. Si un celular deja el Wi-Fi y pasa a los datos móviles, cambia de IP, y esa conexión muere, aunque el servidor y la aplicación estén bien. La aplicación debe darse cuenta, reconectar y reanudar. Algo parecido pasa con un NAT que le cambia el puerto a un flujo inactivo.

QUIC identifica la conexión con un connection ID, un número que viaja en el encabezado, y que no depende de la dirección ni del puerto. El servidor busca la sesión por ese ID. Si el cliente aparece desde otra dirección, con el mismo ID, sigue siendo la misma conexión.

Para ver la idea, sin nada más, está cid_echo.py: un eco de juguete sobre UDP, que no es QUIC, al lado de un eco TCP. En la red c - mb - s, el cliente manda 12 mensajes, y en el sexto se muda de 10.213.1.10 a 10.213.3.20, otra red del router, y borra la dirección vieja:

$ python3 scenarios.py migration
== TCP
0: mensaje-0
1: mensaje-1
2: mensaje-2
3: mensaje-3
4: mensaje-4
5: mensaje-5
-- el cliente cambia a 10.213.3.20; el socket sigue atado a 10.213.1.10
6: sin respuesta (timed out)
7: sin respuesta (timed out)
8: sin respuesta (timed out)
9: sin respuesta (timed out)
10: sin respuesta (timed out)
11: sin respuesta (timed out)
respondidos: 6 de 12 

== UDP con connection ID
0: c0ffee 1 mensaje-0
1: c0ffee 2 mensaje-1
2: c0ffee 3 mensaje-2
3: c0ffee 4 mensaje-3
4: c0ffee 5 mensaje-4
5: c0ffee 6 mensaje-5
-- el cliente cambia a 10.213.3.20, con otro puerto y otro socket
6: c0ffee 7 mensaje-6
7: c0ffee 8 mensaje-7
8: c0ffee 9 mensaje-8
9: c0ffee 10 mensaje-9
10: c0ffee 11 mensaje-10
11: c0ffee 12 mensaje-11
respondidos: 12 de 12 

== Lo que anotó el servidor
sesión c0ffee: cambió de ('10.213.1.10', 53496) a ('10.213.3.20', 49570), sigue en el mensaje 7

El contador 7 lo pone el servidor, que sigue contando en la misma sesión, después de ver una dirección y un puerto distintos. La conexión TCP, en cambio, se queda colgada: el servidor le contesta a una dirección que ya no existe. Con el tiempo, TCP se rendirá.

Lo real es más exigente que el juguete, y conviene saberlo, porque si no este ejemplo es inseguro (RFC 9000, sec. 9):

  • Validación del camino. Cualquiera puede mandar un paquete con una dirección falsa. El servidor debe comprobar que hay alguien allí con frames PATH_CHALLENGE y PATH_RESPONSE, antes de mandarle mucho a esa dirección.
  • Varios IDs. Si el ID fuera siempre el mismo, un observador podría seguir a un celular de una red a otra. Cada lado le entrega al otro varios IDs, y al migrar se usa uno nuevo.
  • El control de congestión empieza de nuevo en el camino nuevo: lo aprendido del anterior no tiene por qué valer.
  • Todo eso va cifrado. Nuestro c0ffee viaja en claro, y cualquiera podría secuestrar la sesión.

CDN: acercar el contenido

Dejamos el transporte y pasamos a la pregunta del comienzo. Una página está en un servidor en Virginia, y el usuario está en Lima. El RTT es de 100 ms o más, y no va a bajar: esa es la velocidad de la luz en fibra, más el camino.

Ya vimos todo lo que se multiplica por ese RTT. Cada handshake lo paga. Cada ida y vuelta de slow start lo paga. Y el throughput de TCP, por la fórmula de la semana 11, es inversamente proporcional a él. Esa es la respuesta a la pregunta 5 de repaso: una CDN da menos retardo y mejor desempeño aun si el ancho de banda no cambia, porque casi todo en una conexión corta se mide en RTT.

Definición. Una CDN (content distribution network) es un conjunto de servidores repartidos por el mundo, que guardan copias del contenido de sus clientes, y que entregan cada petición desde una réplica cercana al usuario.

Da varias cosas a la vez: menos retardo, menos carga sobre el servidor original, menos tráfico por los enlaces de tránsito (que cuestan dinero), y más capacidad para absorber picos y ataques DoS. Una réplica en un data center local o dentro de la red de un proveedor de Internet funciona como la caché de la semana 12: si tiene el objeto, lo sirve; si no, se lo pide al origen y lo guarda, respetando Cache-Control.

Las CDN siguen más o menos dos estrategias: poner muchos servidores pequeños dentro de las redes de acceso, para estar lo más cerca posible, o tener pocos sitios grandes cerca de los IXP, lo que es más simple de operar. La pregunta difícil es otra: si todas las réplicas sirven el mismo nombre, ¿cómo se lleva a cada usuario a la réplica adecuada? Hay dos respuestas, y las vamos a armar con cdn/cdn.clab.yml:

      c1 ---- r1 ==== 60 ms de RTT ==== r2 ---- c2
              | \                       |
             s1  ns                     s2

   r1 AS 65301, r2 AS 65302, s1 y s2 AS 65310 (la CDN), ns es el DNS autoritativo

Son dos regiones, unidas por un enlace largo, con una réplica en cada una. Cada réplica tiene su dirección de unicast (10.213.21.10, 10.213.22.10) y además las dos comparten 10.213.50.1.

Selección por DNS

La más simple es que el DNS decida. El nombre del sitio apunta (con un CNAME) a un nombre de la CDN, y el servidor autoritativo de la CDN contesta con una dirección distinta según quién pregunta. Es lo que hace geo_dns.py, reducido a una tabla:

$ make up-cdn
$ python3 cdn_demo.py dns
c1 pregunta cdn.redes.test a 10.213.30.10: 10.213.21.10
   curl a esa dirección: réplica s1, RTT 0.1 ms
   la otra réplica, 10.213.22.10: RTT 61.7 ms
c2 pregunta cdn.redes.test a 10.213.30.10: 10.213.22.10
   curl a esa dirección: réplica s2, RTT 0.1 ms
   la otra réplica, 10.213.21.10: RTT 62.1 ms

La respuesta completa, en c1:
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 43406
;; flags: qr aa; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; ANSWER SECTION:
cdn.redes.test.		30	IN	A	10.213.21.10

El mismo nombre, el mismo servidor de DNS, y dos respuestas. El flag aa dice que el servidor es autoritativo, y el TTL es de 30 s, para poder cambiar de opinión rápido: es el mismo compromiso que el TTL corto de la semana 12.

En este laboratorio, los clientes preguntan directamente. En Internet el autoritativo no ve al usuario, sino a su resolver, que puede estar en otro país. La CDN adivina por la ubicación del resolver y se puede equivocar. Una extensión de DNS (EDNS Client Subnet) permite que el resolver pase un pedazo de la dirección del cliente, a costa de contar algo más sobre él.

Eso se ve en Internet. Mismo nombre, tres resolvers públicos, desde el mismo contenedor:

$ python3 internet.py dns
www.akamai.com         @1.1.1.1   2.18.21.198 2.18.21.202
www.akamai.com         @8.8.8.8   190.98.161.48 190.98.161.67
www.akamai.com         @9.9.9.9   190.98.161.48 190.98.161.67
www.cloudflare.com     @1.1.1.1   104.16.123.96 104.16.124.96
www.cloudflare.com     @8.8.8.8   104.16.123.96 104.16.124.96
www.cloudflare.com     @9.9.9.9   104.16.123.96 104.16.124.96

Para www.akamai.com, la respuesta depende de qué resolver se use. Para www.cloudflare.com, no. Sus propios resultados pueden diferir en otra red, u otro día. Una dirección idéntica no prueba por sí misma que un sitio use anycast, pero es lo que hay que esperar si lo usa, como veremos:

Selección por anycast

Definición. En anycast, varios servidores anuncian el mismo prefijo por BGP. Los routers no saben que son varios: cada uno ve caminos hacia un mismo prefijo y elige el mejor, como siempre. Cada usuario llega a la réplica que su red considere más cercana.

En nuestra topología, s1 y s2 anuncian 10.213.50.0/24 desde el mismo AS. Miren la tabla de BGP de r1:

$ python3 cdn_demo.py anycast
Rutas de r1 hacia el prefijo de anycast:
BGP routing table entry for 10.213.50.0/24, version 7
Paths: (2 available, best #1, table default)
  Advertised to non peer-group peers:
  10.213.21.10 10.213.100.2
  65310
    10.213.21.10 from 10.213.21.10 (10.213.21.10)
      Origin IGP, metric 0, valid, external, best (AS Path)
      Last update: Mon Nov  2 15:02:07 2026
  65302 65310
    10.213.100.2 from 10.213.100.2 (10.213.100.2)
      Origin IGP, valid, external
      Last update: Mon Nov  2 15:02:07 2026

c1 -> 10.213.50.1: réplica s1, RTT 0.0 ms
c2 -> 10.213.50.1: réplica s2, RTT 0.0 ms

Dos caminos para el mismo prefijo. Gana el de AS path más corto, tal como en la semana 6. (Como allá, no bgp ebgp-requires-policy está puesto a propósito, porque aquí no nos interesan las políticas.) Una misma dirección, dos servidores, sin que los clientes hagan nada.

¿Y si se cae una réplica? Las peticiones siguientes se van a la otra, solo con que deje de anunciar:

$ python3 cdn_demo.py failover
antes:   c1 -> réplica s1, RTT 0.0 ms
s1 deja de anunciar; c1 llega a otra réplica tras 0.4 s
después: c1 -> réplica s2, RTT 61.7 ms
s1 vuelve: c1 -> réplica s1

Medio segundo, sin cambiar ni un DNS. Eso es lo que hace atractivo al anycast. El tiempo depende mucho de la red y de los temporizadores de BGP. El truco tiene una trampa: lo que ocurre con una conexión ya abierta:

$ python3 cdn_demo.py cut
curl en c1, descargando mientras s1 deja de anunciar:
curl: (56) Recv failure: Connection reset by peer
2463792 bytes
código de salida: 56

Los paquetes de esa conexión TCP ahora llegan a s2, que nunca ha oído de ella y responde con un RST. La descarga se pierde tras 2,4 MB. Lo mismo pasa si BGP cambia de ruta por cualquier otra razón: anycast funciona porque las rutas de Internet suelen ser estables en la duración de una conexión corta. Por eso, históricamente, se usó sobre todo para DNS, sobre UDP y con preguntas de un paquete, y la mayoría de sus usos para contenido cuida mucho este riesgo.

Animación de la topología con c1, r1, r2, c2 y las réplicas s1 y s2, y debajo la tabla de BGP de r1 hacia 10.213.50.0/24. Al inicio hay dos caminos, y gana el de AS path 65310 sobre 65302 65310: c1 llega a s1 y c2 llega a s2, con RTT de 0,0 ms. Luego s1 deja de anunciar, se retira su ruta y queda el camino por r2. Tras 0,4 s, c1 llega a s2 con un RTT de 61,7 ms. Después s1 vuelve, y en otra corrida se abre una descarga TCP con s1. Cuando s1 deja de anunciar, los paquetes llegan a s2, que responde con un RST, y curl termina con el error 56Animación de la topología con c1, r1, r2, c2 y las réplicas s1 y s2, y debajo la tabla de BGP de r1 hacia 10.213.50.0/24. Al inicio hay dos caminos, y gana el de AS path 65310 sobre 65302 65310: c1 llega a s1 y c2 llega a s2, con RTT de 0,0 ms. Luego s1 deja de anunciar, se retira su ruta y queda el camino por r2. Tras 0,4 s, c1 llega a s2 con un RTT de 61,7 ms. Después s1 vuelve, y en otra corrida se abre una descarga TCP con s1. Cuando s1 deja de anunciar, los paquetes llegan a s2, que responde con un RST, y curl termina con el error 56
Al cambiar de réplica, una petición nueva sigue funcionando sin tocar el DNS. Una conexión TCP abierta, no: la otra réplica no conoce ese estado.

Idea clave. BGP no sabe nada de latencia, ni de carga. “Más cercana” significa “camino de BGP preferido”, que no siempre es la réplica con menor RTT. El DNS permite elegir con más información, a costa de confiar en la caché y en el resolver. Las CDN reales combinan los dos.

Por DNS Por anycast
Quién decide El autoritativo de la CDN La red, con BGP
Qué información usa Lo que sepa del resolver, carga, mediciones Solo la política y el AS path
Reacción a una falla Lenta, limitada por el TTL y las cachés Rápida en la red
Riesgo El resolver está lejos del usuario Un cambio de ruta corta conexiones TCP

Streaming adaptativo con DASH

El video es la mayor parte del tráfico de Internet. ¿Cómo se manda un video a personas que tienen 2 Mbps en un celular y 500 Mbps en su casa, y cuyo ancho de banda cambia cada minuto?

La idea antigua era una sola calidad, y un servidor especial que empujaba los datos a ritmo fijo. La idea actual invierte todo:

Definición. En DASH (Dynamic Adaptive Streaming over HTTP), el video se corta en segmentos de unos segundos, y cada segmento se codifica en varias calidades. Son archivos comunes, con URL comunes, servidos por HTTP. Un manifiesto (MPD) los describe. El cliente decide, para cada segmento, cuál pedir.

Es una decisión de diseño muy fuerte. El servidor no tiene ningún estado de sesión, y es un servidor web común. Los segmentos se guardan en cualquier caché, y por eso una CDN los sirve sin esfuerzo. Y la inteligencia queda en el extremo, que es el que ve lo que le pasa a su propia red: el mismo principio de la semana 9, ahora en la aplicación.

make_dash.py arma un “video” con bytes al azar y un MPD real:

<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT50S"
     minBufferTime="PT2S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
  <Period>
    <AdaptationSet mimeType="video/mp4" segmentAlignment="true">
      <SegmentTemplate timescale="1" duration="2" startNumber="0"
                       media="$RepresentationID$/seg-$Number$.m4s"/>
      <Representation id="400k" bandwidth="400000"/>
      <Representation id="1000k" bandwidth="1000000"/>
      <Representation id="2500k" bandwidth="2500000"/>
      <Representation id="5000k" bandwidth="5000000"/>
    </AdaptationSet>
  </Period>
</MPD>

bandwidth no es lo que hay en el enlace: es lo que pide esa calidad. El nombre de cada segmento sale del template: 2500k/seg-7.m4s.

El bucle del cliente

El cliente mide lo que puede, y lleva la cuenta de un número fundamental: el buffer, los segundos de video ya bajados que todavía no se reprodujeron. Mientras haya buffer, la imagen sigue. Si llega a cero, se congela: el rebuffering que tanto molesta al usuario.

mientras queden segmentos:
    si el buffer está lleno, esperar
    calidad = choose(calidades, throughput medido, buffer)
    bajar el segmento de esa calidad por HTTP
    medir: bits / tiempo
    actualizar el buffer: -tiempo que tardó, +duración del segmento

Todo se decide en choose. Empecemos con dos estrategias sencillas. fixed pide siempre la mejor. last pide la mejor que cabe en la última medición. dash_sim.py las prueba sobre una red que va a 6 Mbit/s, cae a 1,2 entre los segundos 40 y 70, y se recupera. Primero, siempre la mejor:

$ python3 dash_sim.py fixed
  22   37.7s   5000k   1.7s  6.00M     8.3s      0.0s
  23   39.7s   5000k   7.0s  1.43M     3.0s      0.0s
  24   46.7s   5000k   8.3s  1.20M     2.0s      5.3s
  25   55.0s   5000k   8.3s  1.20M     2.0s      6.3s
  26   63.3s   5000k   7.0s  1.43M     2.0s      5.0s
  27   70.3s   5000k   1.7s  6.00M     2.3s      0.0s
bitrate promedio: 5.00 Mbit/s; cambios: 0; congelado: 16.7 s

(Las columnas son segmento, inicio, calidad, tiempo de descarga, throughput medido, buffer después y tiempo congelado.) Cada segmento de 2 s tarda 8,3 s en bajar. Con la calidad fija, la imagen se congela durante casi 17 s. El promedio es perfecto, y la experiencia es horrible.

Y aquí está el resumen de las dos estrategias, sobre esa red y sobre una que oscila cada 3 s entre 6 y 3 Mbit/s:

$ python3 dash_sim.py last | tail -1
bitrate promedio: 3.65 Mbit/s; cambios: 3; congelado: 0.3 s
$ python3 dash_sim.py last --oscillating | tail -1
bitrate promedio: 3.56 Mbit/s; cambios: 27; congelado: 0.0 s
Gráficos de las dos corridas completas, con la red en 6 Mbit/s que cae a 1,2 entre los segundos 40 y 70. Con fixed, todos los segmentos son de 5000k; los que coinciden con la caída tardan hasta 8,3 s, el buffer baja a 2 s y hay tres tramos de imagen congelada, 16,7 s en total. Con last, un segmento de 5000k tarda 8,3 s y hay 0,3 s congelados; luego baja a 1000k, el buffer se recupera, y al volver la red sube otra vez a 5000kGráficos de las dos corridas completas, con la red en 6 Mbit/s que cae a 1,2 entre los segundos 40 y 70. Con fixed, todos los segmentos son de 5000k; los que coinciden con la caída tardan hasta 8,3 s, el buffer baja a 2 s y hay tres tramos de imagen congelada, 16,7 s en total. Con last, un segmento de 5000k tarda 8,3 s y hay 0,3 s congelados; luego baja a 1000k, el buffer se recupera, y al volver la red sube otra vez a 5000k
Una caída de la red, dos clientes. Con el mismo enlace, fixed tiene mejor promedio y se congela casi 17 s; last baja la calidad a tiempo y casi no se congela.

last sobrevive a la caída con solo 0,3 s congelado, pero, en la red que oscila, salta de calidad 27 veces en 45 segmentos. Una imagen que cambia cada pocos segundos también molesta. Ahí está la tensión de todo ABR (adaptive bitrate): más calidad, sin congelarse y sin saltar. Son tres metas que chocan, y escribir una función mejor es el laboratorio de esta semana.

Un cliente real

dash_client.py hace lo mismo con la red de verdad: lee el MPD por HTTP desde s, baja cada segmento, y mide. Mientras tanto, scenarios.py le baja el enlace a mb, de 6 Mbit/s a 1,2 Mbit/s, entre los segundos 10 y 22. Del lado del servidor hay un python3 -m http.server, y eso es todo, porque cualquier servidor web sirve:

$ python3 scenarios.py dash last
 seg      t  calidad   medido   buffer  congelado
   0   0.3s    400k   3.15M     2.0s      0.0s
   1   1.2s   2500k   5.10M     3.0s      0.0s
   2   3.1s   5000k   5.40M     3.2s      0.0s
   3   4.9s   5000k   5.39M     3.3s      0.0s
   4   6.8s   5000k   5.39M     3.5s      0.0s
   5   8.7s   5000k   5.40M     3.6s      0.0s
   6  12.3s   5000k   2.75M     2.0s      0.0s
   7  16.7s   2500k   1.12M     2.0s      2.4s
   8  18.6s   1000k   1.09M     2.2s      0.0s
   9  20.4s   1000k   1.09M     2.3s      0.0s
  10  22.2s   1000k   1.13M     2.6s      0.0s
  11  22.6s   1000k   4.35M     4.1s      0.0s
  12  23.6s   2500k   5.09M     5.1s      0.0s
  13  25.5s   5000k   5.40M     5.3s      0.0s
...
congelado total: 2.5 s

La forma es la que predijo el simulador, y hay dos cosas que el simulador no tenía:

  • Medir está sesgado por el tamaño. El primer segmento, de 400 kbit/s, mide 3,15 Mbit/s en un enlace de 6. No es que la red sea lenta: es un archivo pequeño, sobre una conexión TCP nueva, y una parte importante del tiempo se va en slow start. Una calidad baja se mide peor, lo que hace más difícil subir. Es un problema real en los ABR.
  • Nunca llega a 6. Mide 5,4 Mbit/s, porque netem cuenta los encabezados en la tasa, y porque el cliente abre una conexión para cada segmento. Es la diferencia entre throughput y goodput.

Comparen con la calidad fija, sobre la misma red:

$ python3 scenarios.py dash fixed
 seg      t  calidad   medido   buffer  congelado
   ...
   4   9.3s   5000k   5.39M     2.6s      0.0s
   5  15.2s   5000k   1.70M     2.0s      3.3s
   6  23.2s   5000k   1.24M     2.0s      6.1s
   7  25.1s   5000k   5.36M     2.1s      0.0s
   ...
congelado total: 9.4 s

9,4 s contra 2,5 s. Las cifras exactas cambian en cada corrida, y lo que importa es esa diferencia.

En la práctica. Los segmentos de los servicios reales duran entre 2 y 10 s, y los buffers son mucho más grandes (decenas de segundos), porque una calidad un poco más baja molesta menos que una imagen congelada. Hay dos familias de ABR: los que miran el throughput, y los que miran sobre todo el buffer, que es un dato seguro y no una estimación. Los buenos clientes combinan los dos. Y este bucle corre encima del control de congestión de TCP o QUIC: dos lazos de control, uno dentro del otro, que no siempre se llevan bien.

Laboratorio: un handshake TLS y HTTP/2 frente a HTTP/3

Trabajen en code/week-13/, dentro del contenedor del curso. Con make se compila sni_peek y se crean los certificados de la clase, en build/pki/. Con make up se levanta la red `c - mb

  • s. Los resultados contra Internet dependen de su conexión. Si en su red no hay salida por UDP, el paso 4 y el 5 fallarán con —http3-only`: eso también es un resultado.
  1. Con openssl s_client, conéctense a un sitio con TLS 1.3 y lean su cadena. python3 internet.py chain www.uni.edu.pe o el sitio que quieran, o directamente openssl s_client -connect sitio:443 -servername sitio -tls1_3. ¿Cuántos certificados manda? ¿Quién firma a quién? ¿Cuándo vence? ¿Qué pasa si el sitio no admite TLS 1.3?

  2. Capturen el handshake. python3 scenarios.py observer deja captures/handshake.pcap, y esa carpeta se ve desde su computadora. Ábranla en Wireshark. Encuentren ClientHello, ServerHello y el SNI. ¿Qué parte ya va cifrada? ¿Dónde está el certificado?

  3. Usen SSLKEYLOGFILE. python3 scenarios.py keylog deja captures/keylog.pcap y captures/keys.log. En Wireshark, vayan a Preferences → Protocols → TLS → (Pre)-Master-Secret log filename, elijan el archivo, y comparen con el paso anterior.

  4. Comparen curl --http2 y curl --http3-only. python3 internet.py h2h3, o con otro sitio que tenga HTTP/3. ¿Cuántos RTT hay en cada caso? Estimen el RTT con la columna de conexión de HTTP/2. ¿Coincide con la tabla de la sección sobre el handshake integrado?

  5. Capturen la conexión QUIC. python3 internet.py quic, y luego python3 quic_initial.py --pcap captures/quic.pcap. ¿Qué se puede leer sin claves, y por qué justamente eso? ¿Qué falta? Expliquen por qué los middleboxes ven tan poco, y qué ganan con ello los que diseñan el protocolo.

  6. Certificado propio. Corran python3 scenarios.py certs, y expliquen qué ataque detiene cada error. Después hagan su propio certificado, con el openssl de pki.sh o con su propio comando, y prueben.

  7. Comparen direcciones de un sitio con CDN. python3 internet.py dns, y agreguen sus propios nombres con dig @resolver nombre A. Revisen en la salida completa de dig la línea SERVER, para confirmar quién contestó. Luego levanten make up-cdn y corran cdn_demo.py, con sus cuatro comandos.

  8. Entreguen P4 y lean el enunciado de P5. P5 es un servidor HTTP/1.1 sobre su rimay; el enunciado no pide TLS.

Algunas actividades se basan en el laboratorio de TLS de Kurose y Ross.

Lo que van a completar

Hay dos archivos con un TODO. Las pruebas los revisan, y se saltan hasta que los completen:

  • lab/abr.py. Escriban la función choose de un cliente DASH. Debe cumplir tres cosas en el simulador: no congelarse nunca en la red NETWORK, promediar más de 1,2 Mbit/s (quedarse siempre en la mínima no vale), y cambiar de calidad menos veces que last_sample en la red OSCILLATING. Ideas: promediar varias muestras, dejar un margen, y bajar con más decisión si el buffer se vacía. Pista para promediar: la media armónica le da más peso a las muestras bajas. Luego pruébenla de verdad, con python3 scenarios.py dash lab/abr.py.
  • lab/mkchain.sh. Armen una cadena de tres niveles: raíz, intermedia y servidor, con las restricciones correctas. Las pruebas comprueban que openssl verify la acepta, y que curl rechaza a un servidor que olvide mandar la intermedia. Es uno de los errores de configuración más comunes de la vida real. Muchas veces no se nota en el navegador, que puede tener la intermedia en su caché.
$ make test
...
setUpClass (test_lab.Abr) ... skipped 'falta completar lab/abr.py'
setUpClass (test_lab.Chain) ... skipped 'falta completar lab/mkchain.sh'
...
Ran 51 tests in 158.186s

OK (skipped=2)

Qué comprueba make test

make test levanta y destruye las dos topologías una tras otra, y no deja nada corriendo, ni siquiera si algo falla. Lo que revisan las pruebas:

  • Criptografía: Diffie-Hellman y RSA de juguete, y que ctr_flip cambie exactamente lo que dice.
  • QUIC: las claves y el paquete del RFC 9001, y el SNI de un curl real.
  • TLS en la red: 1 RTT contra 2, con márgenes amplios; el SNI se lee, el certificado no; las claves abren todo; los cuatro errores de certificado; sni_peek con recortes y largos falsos.
  • 0-RTT: una repetición con y sin la protección.
  • Migración y DASH real: TCP se detiene, el connection ID sigue; la calidad baja durante la caída y vuelve a subir.
  • CDN: las respuestas de DNS, anycast, el cambio de réplica y la conexión que se corta.
  • Internet: solo protocolos, nunca tiempos, y se saltan si no hay salida.

Resumen

  • Tres metas distintas: confidencialidad, integridad y autenticación. Cifrar solo da la primera: ctr_flip.py cambia un monto sin conocer la clave.
  • Simétrica para los datos, rápida y con clave compartida. Clave pública para ponerse de acuerdo y autenticar, miles de veces más lenta. Todo protocolo real es híbrido. TLS 1.3 solo usa AEAD.
  • Diffie-Hellman da un secreto común hablando en público, pero no autentica. RSA (o ECDSA, Ed25519) firma. En TLS 1.3 ya no se usa RSA para intercambiar claves.
  • Hash sin clave; MAC con clave compartida; firma con clave privada, que cualquiera verifica.
  • Certificado: una CA firma “esta clave es de este nombre”. La confianza nace en la lista de raíces del cliente. Un certificado es público: la prueba de identidad es CertificateVerify.
  • TLS 1.3: ClientHello y ServerHello en claro, con su Diffie-Hellman. El resto va cifrado, incluido el certificado. CertificateVerify y Finished atan todo el handshake. Un RTT, contra dos de TLS 1.2: 1,1 y 2,1 en nuestra medición.
  • Forward secrecy: el Diffie-Hellman es efímero y la clave del certificado solo firma, así que robarla no abre lo ya grabado.
  • HTTPS no oculta todo: IP, tamaños, tiempos y normalmente el SNI se ven. sni_peek lo lee sin ninguna clave. Se ocultan la ruta, los headers y el contenido.
  • Los errores de certificado detienen ataques concretos. curl -k conserva el cifrado y pierde la autenticación.
  • QUIC corre en espacio de usuario sobre UDP, porque el kernel y los middleboxes osificaron a TCP. Implementa confiabilidad y congestión. No es poco confiable.
  • Streams independientes: una pérdida solo detiene su stream. En HTTP/2 sobre TCP, detiene a todos. Con un solo stream, no hay diferencia.
  • Handshake integrado: 2 RTT al primer byte contra 3. En Internet medimos 87 ms contra 118 ms.
  • Los Initial se abren sin claves secretas, con el DCID y una sal pública: quic_initial.py lee el SNI. Desde los paquetes Handshake, sí hay secreto. Los ACK y los números de paquete también van protegidos, para que nada se osifique.
  • 0-RTT gana un RTT, a cambio de que los datos se puedan repetir. Solo lo seguro de repetir debe ir ahí.
  • Connection ID: la conexión sobrevive a un cambio de dirección. Requiere validar el camino y cambiar de ID.
  • CDN: menos RTT mejora todo lo que depende de él, aun con el mismo ancho de banda. Se elige con DNS (más información, pero cachés y resolver) o con anycast (rápido y simple, pero una conexión abierta se puede cortar).
  • DASH: archivos comunes en varias calidades, y el cliente decide. El ABR equilibra calidad, congelamiento y saltos.

Ejercicios

  1. Con p = 23, g = 5, a = 6 y b = 15, calculen a mano A, B y el secreto compartido. Comprueben con crypto_toys.dh_shared. ¿Cuántos exponentes tendría que probar un atacante? ¿Cuántos, si p tuviera 2048 bits?

  2. Con p = 11, q = 17 y e = 7, calculen n y d, cifren el mensaje 42 y descífrenlo, con crypto_toys. ¿Qué pasa si intentan cifrar 200? ¿Por qué?

  3. Modifiquen ctr_flip.py para que el destinatario lea pagar 0100 soles a Eva. ¿Qué necesita saber el atacante, y qué no? ¿Funcionaría este ataque contra AES-GCM? Expliquen qué pasaría en el receptor.

  4. Un compañero dice: “Si el servidor manda su certificado, ya sé que es él”. Expliquen por qué está mal, y qué mensaje del handshake lo prueba en realidad. Describan el ataque si el cliente no verificara esa firma.

  5. Un servidor usa TLS 1.3 y, en 2030, le roban la clave privada del certificado. Un atacante tiene grabado todo su tráfico desde 2026. ¿Qué puede leer? ¿Qué puede hacer de ahí en adelante? ¿Qué cambiaría si hubiese usado TLS 1.2 con intercambio RSA? ¿Qué debe hacer el dueño del servidor?

  6. Abran captures/handshake.pcap. ¿Cuántos segmentos TCP ocupa el ClientHello, y por qué? Prueben ahora con curl --curves x25519 en el nodo c, y confirmen con sni_peek qué grupos se ofrecen. ¿Cuántos segmentos ocupa ahora? ¿Qué se pierde con ese cambio?

  7. En la tabla de RTT, agreguen una fila para una conexión reanudada con 0-RTT, en TCP con TLS 1.3 y en QUIC. Dibujen el diagrama de cada uno. ¿Cuál gana, y por cuánto?

  8. Cambien hol_sim.py para que la recuperación tarde 300 ms en lugar de 100, y haya 30 paquetes, con 2 y con 8 streams. Antes de correrlo, anoten su predicción. ¿Cómo cambia la ventaja de QUIC? ¿Con cuántos streams es máxima?

  9. Supongan un servidor que, como el de P5, solo acepta GET y HEAD, y al que le agregan TLS 1.3 con 0-RTT. ¿Es entonces inofensivo repetir el early data? Busquen un caso en que no lo sea: piensen en un GET que cuente visitas, o en lo que un observador aprende viendo la respuesta repetida.

  10. Agreguen un tercer cliente c3 a cdn.clab.yml, conectado a r2 por una red nueva, y una fila para él en geo_dns.py, que lo mande a s1 a propósito. Midan el RTT. ¿Puede el DNS mandar a un cliente lejos de su réplica? ¿Y anycast? Expliquen qué decide cada uno. (Reporten también lo que tuvieron que anunciar por BGP.)

  11. En cdn_demo.py cut, la conexión se corta con un RST. Predigan qué pasaría si s2 tampoco anunciara el prefijo y s1 dejara de anunciarlo: ¿qué ve el curl, y cuándo? Modifiquen la demostración para comprobarlo.

  12. Diseñen un ABR basado solo en el buffer, sin ninguna medición: una función que elija la calidad según buffer_s. Comparen con el suyo de lab/abr.py en dash_sim.py, en las redes NETWORK y OSCILLATING, por promedio, cambios y tiempo congelado. ¿En qué casos gana cada uno? ¿Qué hace su función al comienzo, con el buffer vacío?