Semana 13 — TLS, QUIC y CDNs
Objetivos
- Explicar lo esencial de cifrado simétrico, clave pública, hash y firma digital.
- Seguir un handshake de TLS 1.3 y explicar qué garantiza.
- Explicar por qué QUIC corre sobre UDP y qué mejora frente a TCP más TLS.
- Describir cómo una CDN acerca el contenido y cómo funciona el streaming adaptativo.
Sesión A — Criptografía esencial y TLS 1.3
Lecturas:
- Kurose y Ross, secs. 8.2 (Principles of Cryptography), 8.3 (Message Integrity and Digital Signatures) y 8.6 (Securing TCP and HTTP Connections: TLS).
- CS168, TLS.
Lecturas complementarias:
- RFC 8446, TLS 1.3, secciones 1 y 2.
Temas:
- Qué se quiere: confidencialidad, integridad y autenticación.
- Criptografía simétrica y de clave pública: para qué se usa cada una. Lo mínimo de RSA y Diffie-Hellman, sin demostraciones.
- Hash, MAC y firma digital. Certificados y autoridades de certificación.
- TLS 1.3:
ClientHello,ServerHello, certificado yFinished. Una sola ida y vuelta. - Forward secrecy: por qué se usa Diffie-Hellman efímero.
- Error común: creer que HTTPS oculta todo. Quien observa la red todavía ve direcciones IP y tamaños, y normalmente el nombre del servidor en el SNI.
Sesión B — QUIC y HTTP/3 · CDNs y streaming
Lecturas:
- Kurose y Ross, secs. 2.2.7 (HTTP/3 and QUIC), 2.5 (Video Streaming and Content Distribution Networks) y 3.8 (Evolution of Transport-Layer Functionality).
- RFC 9000, QUIC, secciones 1 y 2.
Lecturas complementarias:
Temas:
- Por qué no se cambia TCP: el kernel y los middleboxes. Por qué QUIC corre sobre UDP, en espacio de usuario.
- Streams independientes: una pérdida ya no bloquea a los demás.
- Handshake integrado con TLS 1.3, y conexión en 0-RTT, con su riesgo de repetición.
- Migración de conexión: los connection IDs sobreviven a un cambio de IP.
- CDNs: elegir un servidor cercano con DNS o anycast.
- Streaming con DASH: segmentos en varias calidades. El cliente decide.
- Error común: creer que UDP hace a QUIC poco confiable. QUIC implementa confiabilidad y control de congestión encima.
Laboratorio — Un handshake TLS y HTTP/2 frente a HTTP/3
Trabajen en code/week-13/.
- Con
openssl s_client, conectarse a un sitio con TLS 1.3 y leer su cadena de certificados. - Capturar el handshake y encontrar
ClientHello,ServerHelloy el SNI. ¿Qué parte ya va cifrada? - Usar
SSLKEYLOGFILEconcurly descifrar la captura en Wireshark. - Con
curl --http2ycurl --http3-only, bajar el mismo recurso y comparar concurl -w. - Capturar la conexión QUIC y comprobar qué se lee sin las claves (SNI y ALPN en los paquetes Initial) y qué no. Explicar por qué los middleboxes ven tan poco.
- Crear un certificado propio con
openssly comprobar el error que dacurl. Explicar qué protege ese error. - Comparar la dirección IP de un sitio con CDN, como
www.akamai.com, resuelta desde dos servidores DNS distintos. El resultado puede cambiar según el día y la red. - Entregar P4 y leer el enunciado de P5.
Algunas actividades se basan en el laboratorio de TLS de Kurose y Ross.
Entregas
- P4 entrega.
- P5 publicado:
wasi(servidor HTTP sobrerimay, publicado en la red). Entrega en semana 15. Verprojects/README.md.
Preguntas de repaso
- Si la clave pública es tan útil, ¿por qué TLS cifra los datos con una clave simétrica?
- ¿Qué evita que un atacante presente un certificado de otro sitio?
- ¿Qué ganamos con forward secrecy si un servidor es comprometido años después?
- Se pierde un paquete de un stream en HTTP/2 y uno en HTTP/3. ¿Qué pasa con los demás streams en cada caso?
- ¿Por qué una CDN puede dar menor retardo aun si el ancho de banda no cambia?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (23)Pruebas (6)Laboratorio (2)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
#!/usr/bin/env python3
"""Los experimentos de CDN de los apuntes, sobre cdn/cdn.clab.yml (make up-cdn).
python3 cdn_demo.py dns cada cliente pregunta el mismo nombre al mismo DNS
python3 cdn_demo.py anycast cada cliente va a la misma dirección y llega a su réplica
python3 cdn_demo.py failover se cae una réplica: qué pasa con las peticiones nuevas
python3 cdn_demo.py cut se cae una réplica en plena descarga: qué pasa con esa conexión
"""
import re
import subprocess
import sys
import time
from netlab import Lab, wait_until
CDN = Lab("cdn", "w13-cdn", "redes-w13-cdn")
ANYCAST = "10.213.50.1"
NS = "10.213.30.10"
NAME = "cdn.redes.test"
def start_services():
"""Levanta los servidores web de las réplicas y el DNS, si no están arriba."""
for node in ("s1", "s2"):
CDN.sh(node, "pkill", "-f", "http.server")
CDN.daemon(node, "exec python3 -m http.server 80 --directory /www >/tmp/http.log 2>&1")
CDN.sh("ns", "pkill", "-f", "geo_dns")
CDN.daemon("ns", "exec python3 /geo_dns.py 53 >/tmp/dns.log 2>&1")
time.sleep(1.0)
def converged():
"""Los dos clientes llegan a la dirección de anycast y al DNS."""
return all(CDN.sh(node, "ping", "-c1", "-W1", target).returncode == 0
for node in ("c1", "c2") for target in (ANYCAST, NS))
def rtt(node, target):
output = CDN.out(node, "ping", "-c5", "-i0.2", "-q", target)
match = re.search(r"= [\d.]+/([\d.]+)/", output)
return float(match[1]) if match else None
def who(node, target=ANYCAST, wait="3"):
done = CDN.sh(node, "curl", "-s", "--max-time", wait, f"http://{target}/quien.txt")
return done.stdout.strip() or f"sin respuesta (curl {done.returncode})"
def dns():
start_services()
for node in ("c1", "c2"):
answer = CDN.out(node, "dig", "+short", f"@{NS}", NAME, "A").strip()
print(f"{node} pregunta {NAME} a {NS}: {answer}")
print(f" curl a esa dirección: {who(node, answer)}, RTT {rtt(node, answer):.1f} ms")
other = "10.213.22.10" if answer == "10.213.21.10" else "10.213.21.10"
print(f" la otra réplica, {other}: RTT {rtt(node, other):.1f} ms")
print("\nLa respuesta completa, en c1:")
print(CDN.out("c1", "dig", f"@{NS}", NAME, "A", "+noall", "+answer", "+comments").strip())
def anycast():
start_services()
print("Rutas de r1 hacia el prefijo de anycast:")
print(CDN.vtysh("r1", "show bgp ipv4 unicast 10.213.50.0/24").strip())
print()
for node in ("c1", "c2"):
print(f"{node} -> {ANYCAST}: {who(node)}, RTT {rtt(node, ANYCAST):.1f} ms")
def withdraw(node):
"""La réplica deja de anunciar el prefijo, como si se cayera el sitio."""
CDN.sh(node, "vtysh", "-c", "conf t", "-c", "router bgp 65310", "-c", "address-family ipv4 unicast",
"-c", "no network 10.213.50.0/24")
def announce(node):
CDN.sh(node, "vtysh", "-c", "conf t", "-c", "router bgp 65310", "-c", "address-family ipv4 unicast",
"-c", "network 10.213.50.0/24")
def failover():
start_services()
print(f"antes: c1 -> {who('c1')}, RTT {rtt('c1', ANYCAST):.1f} ms")
withdraw("s1")
began = time.monotonic()
wait_until(lambda: "s2" in who("c1", wait="1"), seconds=30, step=0.2)
print(f"s1 deja de anunciar; c1 llega a otra réplica tras {time.monotonic() - began:.1f} s")
print(f"después: c1 -> {who('c1')}, RTT {rtt('c1', ANYCAST):.1f} ms")
announce("s1")
wait_until(lambda: "s1" in who("c1", wait="1"), seconds=30, step=0.2)
print(f"s1 vuelve: c1 -> {who('c1')}")
def cut():
"""Una conexión TCP abierta no se muda: la otra réplica no la conoce."""
start_services()
download = subprocess.Popen(
["docker", "exec", CDN.prefix + "c1", "curl", "-sS", "-o", "/dev/null", "--limit-rate", "1M",
"--max-time", "30", "-w", "%{size_download} bytes", f"http://{ANYCAST}/grande.bin"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(2.0)
withdraw("s1")
output = download.communicate(timeout=60)[0].strip()
print("curl en c1, descargando mientras s1 deja de anunciar:")
print(output)
print("código de salida:", download.returncode)
announce("s1")
wait_until(lambda: "s1" in who("c1", wait="1"), seconds=30, step=0.2)
COMMANDS = {"dns": dns, "anycast": anycast, "failover": failover, "cut": cut}
def main():
if len(sys.argv) != 2 or sys.argv[1] not in COMMANDS:
print(__doc__)
return 2
if not wait_until(converged, seconds=60):
print("la red no convergió; ¿corrieron make up-cdn?")
return 1
COMMANDS[sys.argv[1]]()
return 0
if __name__ == "__main__":
sys.exit(main())#!/usr/bin/env python3
"""Por qué un connection ID sobrevive a un cambio de dirección y una conexión TCP no.
No es QUIC: es un eco de juguete sobre UDP, sin cifrado y sin validar el camino, para mostrar
una sola idea. El servidor busca la sesión por el ID que viene en el mensaje, y no por la
dirección y el puerto de origen. Al lado corre un eco TCP, que la busca por los cuatro campos.
cid_echo.py serve <puerto>
cid_echo.py udp <servidor> <puerto> el cliente cambia de red a la mitad
cid_echo.py tcp <servidor> <puerto> lo mismo, con una conexión TCP
cid_echo.py restore devuelve al cliente a su red original
"""
import socket
import subprocess
import sys
import threading
import time
ROUNDS = 12
SWITCH_AT = 6
GAP = 0.1
WAIT = 1.0
DEVICE = "eth1"
# Dos redes del router mb: es lo que le pasa a un celular al dejar el Wi-Fi.
FIRST = ("10.213.1.10", "10.213.1.1")
SECOND = ("10.213.3.20", "10.213.3.1")
def serve_udp(port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", port))
sessions = {}
while True:
data, source = sock.recvfrom(2048)
cid, _, text = data.partition(b" ")
session = sessions.setdefault(cid, {"count": 0, "source": source})
session["count"] += 1
if session["source"] != source:
print(f"sesión {cid.decode()}: cambió de {session['source']} a {source}, sigue en "
f"el mensaje {session['count']}", flush=True)
session["source"] = source
sock.sendto(b"%s %d %s" % (cid, session["count"], text), source)
def serve_tcp(port):
listener = socket.create_server(("0.0.0.0", port))
while True:
conn, _ = listener.accept()
threading.Thread(target=echo_tcp, args=(conn,), daemon=True).start()
def echo_tcp(conn):
with conn:
while True:
data = conn.recv(2048)
if not data:
return
conn.sendall(data)
def move(old, new):
"""Pone la dirección nueva, con su ruta por defecto, y quita la vieja."""
subprocess.run(["ip", "addr", "add", f"{new[0]}/24", "dev", DEVICE], check=True, capture_output=True)
subprocess.run(["ip", "addr", "del", f"{old[0]}/24", "dev", DEVICE], check=True, capture_output=True)
subprocess.run(["ip", "route", "replace", "default", "via", new[1]], check=True, capture_output=True)
def client_udp(server, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((FIRST[0], 0))
sock.settimeout(WAIT)
answered = 0
for number in range(ROUNDS):
if number == SWITCH_AT:
move(FIRST, SECOND)
sock.close()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((SECOND[0], 0))
sock.settimeout(WAIT)
print(f"-- el cliente cambia a {SECOND[0]}, con otro puerto y otro socket", flush=True)
sock.sendto(b"c0ffee mensaje-%d" % number, (server, port))
try:
reply = sock.recv(2048)
answered += 1
print(f"{number}: {reply.decode()}", flush=True)
except socket.timeout:
print(f"{number}: sin respuesta", flush=True)
time.sleep(GAP)
print(f"respondidos: {answered} de {ROUNDS}", flush=True)
return answered
def client_tcp(server, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((FIRST[0], 0))
sock.connect((server, port))
sock.settimeout(WAIT)
answered = 0
for number in range(ROUNDS):
if number == SWITCH_AT:
move(FIRST, SECOND)
print(f"-- el cliente cambia a {SECOND[0]}; el socket sigue atado a {FIRST[0]}", flush=True)
try:
sock.sendall(b"mensaje-%d" % number)
reply = sock.recv(2048)
answered += 1
print(f"{number}: {reply.decode()}", flush=True)
except OSError as error:
print(f"{number}: sin respuesta ({error})", flush=True)
time.sleep(GAP)
sock.close()
print(f"respondidos: {answered} de {ROUNDS}", flush=True)
return answered
def main():
if len(sys.argv) == 3 and sys.argv[1] == "serve":
port = int(sys.argv[2])
threading.Thread(target=serve_tcp, args=(port,), daemon=True).start()
serve_udp(port)
elif len(sys.argv) == 4 and sys.argv[1] in ("udp", "tcp"):
client = client_udp if sys.argv[1] == "udp" else client_tcp
client(sys.argv[2], int(sys.argv[3]))
elif len(sys.argv) == 2 and sys.argv[1] == "restore":
move(SECOND, FIRST)
else:
print(__doc__)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())#!/usr/bin/env python3
"""Criptografía de juguete: Diffie-Hellman, RSA, hash y MAC con números que se ven.
Los números de DH y RSA son diminutos a propósito, para poder seguir las cuentas a mano.
Este código no protege nada: se rompe en menos de un segundo, y la demostración lo hace.
"""
import hashlib
import hmac
def dh_shared(p, g, a, b):
"""Diffie-Hellman: cada lado publica g^x mod p y llega al mismo secreto."""
big_a = pow(g, a, p)
big_b = pow(g, b, p)
return big_a, big_b, pow(big_b, a, p), pow(big_a, b, p)
def dh_break(p, g, public):
"""Logaritmo discreto a la fuerza: con p pequeño, basta probar exponentes."""
value = 1
for exponent in range(p):
if value == public:
return exponent
value = value * g % p
raise ValueError("no hay exponente")
def rsa_keys(p, q, e):
"""Devuelve (n, e, d). d es el inverso de e módulo (p-1)(q-1)."""
phi = (p - 1) * (q - 1)
return p * q, e, pow(e, -1, phi)
def rsa_apply(message, exponent, n):
"""La misma operación cifra, descifra, firma y verifica: solo cambia el exponente."""
if not 0 <= message < n:
raise ValueError("el mensaje debe estar entre 0 y n-1")
return pow(message, exponent, n)
def differing_bits(first, second):
return sum(bin(x ^ y).count("1") for x, y in zip(first, second))
def main():
print("== Diffie-Hellman con p=2087, g=5")
p, g, a, b = 2087, 5, 731, 1289
big_a, big_b, secret_a, secret_b = dh_shared(p, g, a, b)
print(f"Ana elige a={a} y publica A = g^a mod p = {big_a}")
print(f"Beto elige b={b} y publica B = g^b mod p = {big_b}")
print(f"Ana calcula B^a mod p = {secret_a}")
print(f"Beto calcula A^b mod p = {secret_b}")
print(f"quien solo vio A y B, probando exponentes, halla a={dh_break(p, g, big_a)}")
print("\n== RSA con p=61, q=53, e=17")
n, e, d = rsa_keys(61, 53, 17)
message = 65
cipher = rsa_apply(message, e, n)
print(f"n={n} e={e} d={d}")
print(f"cifrar con la pública: {message}^{e} mod {n} = {cipher}")
print(f"descifrar con la privada: {cipher}^{d} mod {n} = {rsa_apply(cipher, d, n)}")
signature = rsa_apply(message, d, n)
print(f"firmar con la privada: {message}^{d} mod {n} = {signature}")
print(f"verificar con la pública: {signature}^{e} mod {n} = {rsa_apply(signature, e, n)}")
print("\n== Hash: un carácter cambia cerca de la mitad de los bits")
one = hashlib.sha256(b"pagar 100 soles").digest()
two = hashlib.sha256(b"pagar 900 soles").digest()
print("sha256('pagar 100 soles') =", one.hex())
print("sha256('pagar 900 soles') =", two.hex())
print(f"bits distintos: {differing_bits(one, two)} de {len(one) * 8}")
print("\n== MAC: el hash solo no autentica, porque cualquiera lo recalcula")
key = b"clave compartida"
print("hmac(clave, 'pagar 100 soles') =", hmac.new(key, b"pagar 100 soles", "sha256").hexdigest())
print("hmac(otra, 'pagar 100 soles') =", hmac.new(b"otra", b"pagar 100 soles", "sha256").hexdigest())
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""Cifrar no es proteger la integridad: cambiar bits de un texto cifrado con AES-CTR.
El atacante no conoce la clave, pero sí el formato del mensaje. Cambia un carácter con un
XOR sobre el texto cifrado, y el destinatario descifra otra cosa sin notar nada. Un MAC sí
lo detecta. Por eso TLS 1.3 solo usa cifrado autenticado (AEAD).
"""
import hmac
import subprocess
KEY = "000102030405060708090a0b0c0d0e0f"
IV = "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf"
MAC_KEY = b"otra clave, solo para el MAC"
MESSAGE = b"pagar 0100 soles a Ana"
def aes_ctr(data):
"""AES-128-CTR con el comando openssl. Cifrar y descifrar son la misma operación."""
done = subprocess.run(["openssl", "enc", "-aes-128-ctr", "-K", KEY, "-iv", IV],
input=data, capture_output=True, check=True)
return done.stdout
def flip(cipher, position, old, new):
"""Sin la clave: si se sabe qué byte hay en una posición, se puede poner otro."""
tampered = bytearray(cipher)
tampered[position] ^= old ^ new
return bytes(tampered)
def main():
cipher = aes_ctr(MESSAGE)
tag = hmac.new(MAC_KEY, cipher, "sha256").digest()
print("mensaje: ", MESSAGE.decode())
print("cifrado: ", cipher.hex())
tampered = flip(cipher, MESSAGE.index(b"0100"), ord("0"), ord("9"))
print("cifrado alterado:", tampered.hex())
print("se descifra como:", aes_ctr(tampered).decode())
valid = hmac.compare_digest(hmac.new(MAC_KEY, tampered, "sha256").digest(), tag)
print("MAC del original sobre el alterado:", "válido" if valid else "NO coincide, se descarta")
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""Cliente DASH real, sin reproducir nada: lee el MPD, baja segmentos y lleva la cuenta del buffer.
La decisión de calidad la toma aquí el cliente, en cada segmento. El servidor solo guarda archivos.
dash_client.py http://servidor:puerto/video.mpd [fixed|last|ruta/a/abr.py] [--segments N]
"""
import argparse
import sys
import time
import urllib.request
import xml.etree.ElementTree as ET
from dash_sim import BUFFER_MAX_S, fixed_top, last_sample, load_choose
NS = {"mpd": "urn:mpeg:dash:schema:mpd:2011"}
def read_manifest(url):
"""Devuelve (lista de (bitrate, id) ordenada, template, duración de segmento, total)."""
with urllib.request.urlopen(url, timeout=10) as answer:
root = ET.fromstring(answer.read())
adaptation = root.find("mpd:Period/mpd:AdaptationSet", NS)
template = adaptation.find("mpd:SegmentTemplate", NS)
duration = int(template.get("duration")) / int(template.get("timescale"))
total = int(root.get("mediaPresentationDuration")[2:-1])
reps = sorted((int(rep.get("bandwidth")), rep.get("id"))
for rep in adaptation.findall("mpd:Representation", NS))
return reps, template.get("media"), duration, int(total / duration)
def play(mpd_url, choose, limit=None):
"""Baja todos los segmentos. Imprime una línea por segmento; devuelve los segundos congelados."""
reps, media, duration, total = read_manifest(mpd_url)
bitrates = [bitrate for bitrate, _ in reps]
base = mpd_url.rsplit("/", 1)[0]
samples = []
buffer_s = 0.0
frozen_total = 0.0
start = time.monotonic()
print(" seg t calidad medido buffer congelado", flush=True)
for number in range(limit or total):
if buffer_s > BUFFER_MAX_S - duration:
pause = buffer_s - (BUFFER_MAX_S - duration)
time.sleep(pause)
buffer_s -= pause
quality = choose(bitrates, list(samples), buffer_s)
path = media.replace("$RepresentationID$", reps[quality][1]).replace("$Number$", str(number))
began = time.monotonic()
with urllib.request.urlopen(f"{base}/{path}", timeout=30) as answer:
size = len(answer.read())
took = time.monotonic() - began
samples.append(size * 8 / took)
frozen = max(0.0, took - buffer_s) if number > 0 else 0.0
frozen_total += frozen
buffer_s = max(0.0, buffer_s - took) + duration
print(f"{number:4} {time.monotonic() - start:5.1f}s {bitrates[quality] // 1000:6}k "
f"{samples[-1] / 1e6:6.2f}M {buffer_s:7.1f}s {frozen:8.1f}s", flush=True)
print(f"congelado total: {frozen_total:.1f} s", flush=True)
return frozen_total
def main():
parser = argparse.ArgumentParser(description="Cliente DASH")
parser.add_argument("url")
parser.add_argument("strategy", nargs="?", default="last")
parser.add_argument("--segments", type=int)
args = parser.parse_args()
choose = {"fixed": fixed_top, "last": last_sample}.get(args.strategy) or load_choose(args.strategy)
play(args.url, choose, args.segments)
return 0
if __name__ == "__main__":
sys.exit(main())#!/usr/bin/env python3
"""Simulador de un cliente DASH: misma decisión que el cliente real, tiempos exactos.
La red es una lista de tramos (hasta qué segundo, a cuántos bit/s). Un segmento tarda lo que
indique esa lista, y el video avanza mientras tanto. Si el buffer se vacía, la imagen se congela.
Cada estrategia es una función choose(bitrates, samples, buffer_s) que devuelve la calidad.
dash_sim.py [fixed|last|ruta/a/abr.py] [--oscillating]
"""
import importlib.util
import sys
BITRATES = [400_000, 1_000_000, 2_500_000, 5_000_000]
SEGMENT_S = 2
BUFFER_MAX_S = 10
# 40 s buenos, 30 s de caída, y se recupera.
NETWORK = [(40, 6_000_000), (70, 1_200_000), (float("inf"), 6_000_000)]
# Una red que sube y baja cada 3 s, sin llegar a caer.
OSCILLATING = [(3 * (i + 1), 6_000_000 if i % 2 == 0 else 3_000_000) for i in range(200)] + \
[(float("inf"), 6_000_000)]
def fixed_top(bitrates, samples, buffer_s):
"""Siempre la mejor calidad: lo que pide quien no quiere pensar en la red."""
del samples, buffer_s
return len(bitrates) - 1
def last_sample(bitrates, samples, buffer_s):
"""La mejor calidad que cabe en la última medición, sin margen."""
del buffer_s
if not samples:
return 0
return max((i for i, rate in enumerate(bitrates) if rate <= samples[-1]), default=0)
def load_choose(path):
"""Carga la función choose de un archivo de Python."""
spec = importlib.util.spec_from_file_location("abr_module", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.choose
def download_time(bits, start, network):
"""Avanza por los tramos de la red hasta terminar de bajar esos bits."""
now = start
left = bits
for until, rate in network:
if now >= until:
continue
possible = (until - now) * rate
if possible >= left:
return now + left / rate - start
left -= possible
now = until
raise ValueError("la red no alcanza")
def simulate(choose, segments=45, network=None, bitrates=None):
"""Devuelve una fila por segmento y el total de segundos congelado."""
network = network or NETWORK
bitrates = bitrates or BITRATES
now = 0.0
buffer_s = 0.0
samples = []
rows = []
frozen_total = 0.0
for number in range(segments):
# Con el buffer lleno se espera: no tiene sentido bajar medio video por adelantado.
if buffer_s > BUFFER_MAX_S - SEGMENT_S:
pause = buffer_s - (BUFFER_MAX_S - SEGMENT_S)
now += pause
buffer_s -= pause
quality = choose(bitrates, list(samples), buffer_s)
bits = bitrates[quality] * SEGMENT_S
took = download_time(bits, now, network)
frozen = max(0.0, took - buffer_s) if number > 0 else 0.0
frozen_total += frozen
buffer_s = max(0.0, buffer_s - took) + SEGMENT_S
now += took
samples.append(bits / took)
rows.append({"segment": number, "start": now - took, "bitrate": bitrates[quality], "took": took,
"sample": samples[-1], "buffer": buffer_s, "frozen": frozen})
return rows, frozen_total
def score(rows):
"""Bitrate promedio y número de cambios de calidad."""
rates = [row["bitrate"] for row in rows]
switches = sum(1 for a, b in zip(rates, rates[1:]) if a != b)
return sum(rates) / len(rates), switches
def show(rows, frozen):
print(" seg inicio calidad tarda medido buffer congelado")
for row in rows:
print(f"{row['segment']:4} {row['start']:6.1f}s {row['bitrate'] // 1000:6}k {row['took']:5.1f}s "
f"{row['sample'] / 1e6:5.2f}M {row['buffer']:7.1f}s {row['frozen']:8.1f}s")
average, switches = score(rows)
print(f"bitrate promedio: {average / 1e6:.2f} Mbit/s; cambios: {switches}; congelado: {frozen:.1f} s")
def main():
args = [arg for arg in sys.argv[1:] if arg != "--oscillating"]
name = args[0] if args else "fixed"
choose = {"fixed": fixed_top, "last": last_sample}.get(name) or load_choose(name)
network = OSCILLATING if "--oscillating" in sys.argv else NETWORK
rows, frozen = simulate(choose, network=network)
show(rows, frozen)
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""Un servidor DNS autoritativo mínimo que contesta según quién pregunta.
Es la idea de la selección por DNS de una CDN, reducida a una tabla: el prefijo de quien
consulta decide qué réplica se devuelve. Solo sabe contestar registros A, de un solo nombre.
geo_dns.py <puerto>
"""
import ipaddress
import socket
import struct
import sys
NAME = "cdn.redes.test"
TTL = 30
# Quien consulta -> réplica más cercana. La última fila atrapa todo lo demás.
TABLE = [
(ipaddress.ip_network("10.213.11.0/24"), "10.213.21.10"),
(ipaddress.ip_network("10.213.12.0/24"), "10.213.22.10"),
(ipaddress.ip_network("0.0.0.0/0"), "10.213.21.10"),
]
TYPE_A = 1
CLASS_IN = 1
FLAG_RESPONSE_AA = 0x8400
RCODE_NXDOMAIN = 3
RCODE_NOTIMP = 4
def read_question(query):
"""Devuelve (nombre, tipo, clase, fin de la pregunta). Sin compresión: es la primera pregunta."""
labels = []
pos = 12
while query[pos] != 0:
length = query[pos]
labels.append(query[pos + 1:pos + 1 + length].decode("ascii"))
pos += 1 + length
pos += 1
qtype, qclass = struct.unpack("!HH", query[pos:pos + 4])
return ".".join(labels).lower(), qtype, qclass, pos + 4
def answer_for(source):
address = ipaddress.ip_address(source)
for network, replica in TABLE:
if address in network:
return replica
raise ValueError("la tabla no tiene ruta por defecto")
def respond(query, source):
"""Arma la respuesta a una consulta, o None si no se puede ni leer."""
if len(query) < 17:
return None
query_id = query[:2]
try:
name, qtype, qclass, end = read_question(query)
except (IndexError, UnicodeDecodeError, struct.error):
return None
question = query[12:end]
if name != NAME:
return query_id + struct.pack("!HHHHH", FLAG_RESPONSE_AA | RCODE_NXDOMAIN, 1, 0, 0, 0) + question
if qtype != TYPE_A or qclass != CLASS_IN:
return query_id + struct.pack("!HHHHH", FLAG_RESPONSE_AA | RCODE_NOTIMP, 1, 0, 0, 0) + question
replica = answer_for(source)
# 0xc00c es un puntero al nombre que ya está en la pregunta.
record = struct.pack("!HHHIH", 0xc00c, TYPE_A, CLASS_IN, TTL, 4) + socket.inet_aton(replica)
return query_id + struct.pack("!HHHHH", FLAG_RESPONSE_AA, 1, 1, 0, 0) + question + record
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 53
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", port))
while True:
query, peer = sock.recvfrom(512)
reply = respond(query, peer[0])
if reply:
sock.sendto(reply, peer)
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""Un paquete perdido, tres streams: cuándo recibe la aplicación cada dato.
Simulador, no una medición: no hay servidor HTTP/3 en el contenedor del curso. El modelo se
reduce a lo que importa aquí, sin ventanas, ni ACK, ni control de congestión:
- Los paquetes salen de a uno cada TX ms, y llegan ONE_WAY ms después.
- Cada paquete lleva datos de un stream, por turnos: A, B, C, A, B, C...
- El paquete perdido se recupera con una retransmisión que llega RECOVERY ms más tarde.
La única diferencia entre los modos es qué ordena el receptor:
tcp hay un solo orden, el del stream de bytes de TCP. Un hueco detiene todo lo que sigue.
quic hay un orden por stream. Un hueco detiene solo a su stream.
"""
import argparse
TX = 10
ONE_WAY = 50
RECOVERY = 100
STREAMS = "ABC"
def packets(count, streams=STREAMS):
"""Lista de (número, stream, número dentro del stream)."""
seen = {name: 0 for name in streams}
result = []
for number in range(count):
name = streams[number % len(streams)]
result.append((number, name, seen[name]))
seen[name] += 1
return result
def arrival_times(count, lost):
times = {number: number * TX + ONE_WAY for number in range(count)}
if lost is not None:
times[lost] += RECOVERY
return times
def deliver(count, lost, mode, streams=STREAMS):
"""Devuelve {número de paquete: instante en que la aplicación recibe sus datos}."""
times = arrival_times(count, lost)
delivered = {}
latest = {}
for number, name, _ in packets(count, streams):
key = "todos" if mode == "tcp" else name
# Se entrega cuando llegó él y todo lo anterior en su mismo orden.
latest[key] = max(latest.get(key, 0), times[number])
delivered[number] = latest[key]
return delivered
def delayed_by_stream(count, lost, mode, streams=STREAMS):
"""Por stream: cuántos paquetes esperaron más de lo necesario y cuánto, como máximo."""
delivered = deliver(count, lost, mode, streams)
clean = arrival_times(count, None)
result = {name: [0, 0] for name in streams}
for number, name, _ in packets(count, streams):
wait = delivered[number] - clean[number]
if wait > 0:
result[name][0] += 1
result[name][1] = max(result[name][1], wait)
return result
def table(count, lost):
tcp = deliver(count, lost, "tcp")
quic = deliver(count, lost, "quic")
times = arrival_times(count, lost)
print("paq stream llega entrega(TCP) entrega(QUIC)")
for number, name, index in packets(count):
mark = " <- perdido, llega retransmitido" if number == lost else ""
print(f"{number:3} {name}{index:<5} {times[number]:5} {tcp[number]:12} {quic[number]:14}{mark}")
def summary(count, lost):
print(f"\n{'':6}{'TCP: esperan, máx.':>22}{'QUIC: esperan, máx.':>24}")
tcp = delayed_by_stream(count, lost, "tcp")
quic = delayed_by_stream(count, lost, "quic")
for name in STREAMS:
print(f"{name:6}{tcp[name][0]:>12}{tcp[name][1]:>7} ms{quic[name][0]:>15}{quic[name][1]:>6} ms")
def main():
parser = argparse.ArgumentParser(description="Un paquete perdido: TCP frente a QUIC")
parser.add_argument("--packets", type=int, default=12)
parser.add_argument("--lost", type=int, default=3, help="número del paquete perdido")
args = parser.parse_args()
print(f"TX={TX} ms, retardo={ONE_WAY} ms, recuperación={RECOVERY} ms, se pierde el paquete {args.lost}")
table(args.packets, args.lost)
summary(args.packets, args.lost)
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""Los pasos del laboratorio que necesitan Internet. Corren en el contenedor del curso.
python3 internet.py chain [sitio] versión de TLS y cadena de certificados
python3 internet.py h2h3 [sitio] HTTP/2 frente a HTTP/3, en medianas de 11 descargas
python3 internet.py quic [sitio] captura una conexión QUIC: qué se lee sin claves y con claves
python3 internet.py dns el mismo nombre, preguntado a tres resolvers públicos
Los números dependen de su conexión y del sitio.
"""
import os
import re
import statistics
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
CAPTURES = os.path.join(HERE, "captures")
DEFAULT_SITE = "www.cloudflare.com"
DEFAULT_PATH = "/cdn-cgi/trace"
RESOLVERS = ["1.1.1.1", "8.8.8.8", "9.9.9.9"]
CDN_NAMES = ["www.akamai.com", "www.cloudflare.com"]
FORMAT = "%{http_version} %{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}"
def run(args, **kwargs):
return subprocess.run(args, capture_output=True, text=True, timeout=kwargs.pop("timeout", 60), **kwargs)
def chain(site):
"""openssl s_client imprime el certificado y la cadena; nos quedamos con lo que importa."""
done = run(["openssl", "s_client", "-connect", f"{site}:443", "-servername", site, "-tls1_3"],
input="", timeout=20)
for line in done.stdout.splitlines():
if re.match(r"\s*(\d+ s:|i:|a:|v:)", line) or line.startswith(("New, ", "Verify return code", "Verification")):
print(line)
print(next((line.strip() for line in done.stdout.splitlines() if "Protocol" in line), ""))
def sample(flag, site, path):
done = run(["curl", "-s", "-o", "/dev/null", flag, "--max-time", "10", "-w", FORMAT, f"https://{site}{path}"])
parts = done.stdout.split()
return parts[0], [float(value) * 1000 for value in parts[1:]]
def h2h3(site, path, rounds=11):
print(f"{site}{path}: medianas de {rounds} conexiones nuevas, en ms")
print(f"{'':8}{'versión':>8}{'DNS':>7}{'conexión':>10}{'TLS listo':>11}{'primer byte':>13}{'total':>8}")
for title, flag in (("HTTP/2", "--http2"), ("HTTP/3", "--http3-only")):
rows = []
version = "?"
for _ in range(rounds):
version, values = sample(flag, site, path)
rows.append(values)
lookup, connect, tls, first, total = [statistics.median(col) for col in zip(*rows)]
print(f"{title:8}{version:>8}{lookup:7.1f}{connect:10.1f}{tls:11.1f}{first:13.1f}{total:8.1f}")
def quic(site, path):
"""Captura en la interfaz por defecto del contenedor, filtrando el puerto 443 de UDP."""
os.makedirs(CAPTURES, exist_ok=True)
pcap = os.path.join(CAPTURES, "quic.pcap")
keys = os.path.join(CAPTURES, "quic-keys.log")
for name in (pcap, keys):
if os.path.exists(name):
os.remove(name)
address = run(["dig", "+short", site, "A"]).stdout.split()[-1]
capture = subprocess.Popen(["tcpdump", "-i", "any", "-n", "-U", "-s0", "-w", pcap, f"udp port 443 and host {address}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1.5)
env = dict(os.environ, SSLKEYLOGFILE=keys)
subprocess.run(["curl", "-s", "-o", "/dev/null", "--http3-only", "--resolve", f"{site}:443:{address}",
"--max-time", "10", f"https://{site}{path}"], env=env, check=False)
time.sleep(1)
capture.terminate()
capture.wait()
fields = ["-T", "fields", "-e", "frame.number", "-e", "udp.length", "-e", "_ws.col.Info"]
print("Sin claves:")
print(run(["tshark", "-r", pcap, "-c", "11", *fields]).stdout.rstrip())
print("\nSNI y ALPN que tshark lee sin claves:")
print(run(["tshark", "-r", pcap, "-Y", "tls.handshake.type == 1", "-T", "fields",
"-e", "tls.handshake.extensions_server_name", "-e", "tls.handshake.extensions_alpn_str"]).stdout.strip())
print("\nCon SSLKEYLOGFILE, los paquetes que llevan streams:")
decrypted = run(["tshark", "-r", pcap, "-o", f"tls.keylog_file:{keys}", "-T", "fields",
"-e", "frame.number", "-e", "_ws.col.Protocol", "-e", "_ws.col.Info"]).stdout
shown = [line for line in decrypted.splitlines() if "STREAM" in line]
print("\n".join(shown[:8]))
def dns():
for name in CDN_NAMES:
for resolver in RESOLVERS:
answer = run(["dig", "+short", "+time=3", f"@{resolver}", name, "A"]).stdout.split()
addresses = [item for item in answer if not item.endswith(".")]
print(f"{name:22} @{resolver:9} {' '.join(sorted(addresses))}")
def main():
if len(sys.argv) < 2:
print(__doc__)
return 2
command = sys.argv[1]
site = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_SITE
path = DEFAULT_PATH if site == DEFAULT_SITE else "/"
if command == "chain":
chain(site)
elif command == "h2h3":
h2h3(site, path)
elif command == "quic":
quic(site, path)
elif command == "dns":
dns()
else:
print(__doc__)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())#!/usr/bin/env python3
"""Prepara un "video" para DASH: varias calidades, segmentos cortos y un manifiesto MPD.
El contenido son bytes al azar del tamaño correcto: a la red y al cliente les da igual. El MPD
sí tiene la forma de los reales (Period, AdaptationSet, Representation y SegmentTemplate).
make_dash.py <carpeta> [--segments 15] [--duration 2]
"""
import argparse
import os
BITRATES = [400_000, 1_000_000, 2_500_000, 5_000_000]
MPD = """<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT{total}S"
minBufferTime="PT{duration}S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
<Period>
<AdaptationSet mimeType="video/mp4" segmentAlignment="true">
<SegmentTemplate timescale="1" duration="{duration}" startNumber="0"
media="$RepresentationID$/seg-$Number$.m4s"/>
{representations}
</AdaptationSet>
</Period>
</MPD>
"""
def build(folder, segments, duration, bitrates=None):
bitrates = bitrates or BITRATES
os.makedirs(folder, exist_ok=True)
lines = []
for bitrate in bitrates:
name = f"{bitrate // 1000}k"
lines.append(f' <Representation id="{name}" bandwidth="{bitrate}"/>')
os.makedirs(os.path.join(folder, name), exist_ok=True)
size = bitrate * duration // 8
for number in range(segments):
with open(os.path.join(folder, name, f"seg-{number}.m4s"), "wb") as file:
file.write(os.urandom(size))
with open(os.path.join(folder, "video.mpd"), "w", encoding="utf-8") as file:
file.write(MPD.format(total=segments * duration, duration=duration, representations="\n".join(lines)))
def main():
parser = argparse.ArgumentParser(description="Genera segmentos y un MPD")
parser.add_argument("folder")
parser.add_argument("--segments", type=int, default=15)
parser.add_argument("--duration", type=int, default=2)
args = parser.parse_args()
build(args.folder, args.segments, args.duration)
if __name__ == "__main__":
main()"""Ayudas de la semana 13: levantar topologías y correr comandos en sus nodos."""
import os
import subprocess
import time
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
PKI = os.path.join(HERE, "build", "pki")
# La solución no se publica. Sin ella, las pruebas usan lo que ustedes escriben en lab/.
ANSWERS = os.path.join(HERE, "solution")
if not os.path.isdir(ANSWERS):
ANSWERS = os.path.join(HERE, "lab")
def answer_path(name):
"""Devuelve la ruta de un archivo del laboratorio, o salta si sigue con el TODO."""
path = os.path.join(ANSWERS, name)
with open(path, encoding="utf-8") as file:
if "TODO" in file.read():
raise unittest.SkipTest(f"falta completar lab/{name}")
return path
def run(args, timeout=240, **kwargs):
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, **kwargs)
def make_pki():
"""Crea los certificados de la clase, si no están."""
if not os.path.exists(os.path.join(PKI, "selfsigned.crt")):
subprocess.run(["sh", os.path.join(HERE, "pki.sh"), PKI], check=True, capture_output=True)
return PKI
class Lab:
"""Una topología de Containerlab que se levanta y se destruye desde las pruebas."""
def __init__(self, folder, name, network):
self.folder = os.path.join(HERE, folder)
self.topology = f"{folder}.clab.yml"
self.prefix = f"clab-{name}-"
self.network = network
def deploy(self):
done = subprocess.run(
["containerlab", "deploy", "-t", self.topology, "--reconfigure"],
cwd=self.folder, capture_output=True, text=True, timeout=300)
if done.returncode != 0:
raise RuntimeError(done.stdout + done.stderr)
def destroy(self):
subprocess.run(["containerlab", "destroy", "-t", self.topology, "--cleanup"],
cwd=self.folder, capture_output=True, text=True, timeout=300)
subprocess.run(["docker", "network", "rm", self.network], capture_output=True, text=True)
def sh(self, node, *command, timeout=60):
"""Corre un comando en un nodo y devuelve el resultado completo."""
return run(["docker", "exec", self.prefix + node, *command], timeout=timeout)
def out(self, node, *command, timeout=60):
"""Corre un comando en un nodo y devuelve stdout y stderr juntos."""
done = self.sh(node, *command, timeout=timeout)
return done.stdout + done.stderr
def daemon(self, node, command):
"""Deja un comando corriendo en segundo plano dentro de un nodo."""
run(["docker", "exec", "-d", self.prefix + node, "sh", "-c", command])
def vtysh(self, node, command):
return self.out(node, "vtysh", "-c", command)
def wait_until(predicate, seconds=30, step=0.5):
"""Espera a que una condición se cumpla; devuelve si se cumplió."""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(step)
return predicate()#!/bin/sh
# Crea una autoridad de certificación de la clase y varios certificados para demo.redes.test:
# ca.crt la CA, que solo conoce quien la instale
# good.crt firmado por la CA, con el nombre correcto
# wrongname.crt firmado por la CA, pero para otro nombre
# expired.crt firmado por la CA, ya vencido
# selfsigned.crt firmado por sí mismo
# Uso: pki.sh <carpeta>. Las claves son de clase: no sirven para nada fuera de este laboratorio.
set -eu
out=$1
mkdir -p "$out"
cd "$out"
key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
key ca.key
openssl req -x509 -new -key ca.key -days 30 -subj "/O=Curso de Redes/CN=CA de la clase" -out ca.crt
# leaf <archivo> <nombre> [opciones de openssl x509]
leaf() {
name=$1; dns=$2; shift 2
key "$name.key"
openssl req -new -key "$name.key" -subj "/CN=$dns" -out "$name.csr"
printf 'subjectAltName=DNS:%s\nbasicConstraints=CA:FALSE\n' "$dns" > "$name.ext"
openssl x509 -req -in "$name.csr" -CA ca.crt -CAkey ca.key -CAcreateserial \
-extfile "$name.ext" -out "$name.crt" "$@" 2>/dev/null
rm -f "$name.csr" "$name.ext"
}
leaf good demo.redes.test -days 30
leaf wrongname otro.redes.test -days 30
leaf expired demo.redes.test -not_before 20200101000000Z -not_after 20200201000000Z
key selfsigned.key
openssl req -x509 -new -key selfsigned.key -days 30 -subj "/CN=demo.redes.test" \
-addext "subjectAltName=DNS:demo.redes.test" -out selfsigned.crt#!/usr/bin/env python3
"""Descifra el primer vuelo de una conexión QUIC sin tener ninguna clave privada.
Las claves de los paquetes Initial salen del Destination Connection ID, que viaja a la vista,
y de una sal fija publicada en el RFC 9001. Cualquiera en el camino puede repetir la cuenta.
quic_initial.py --vector comprueba las claves con el ejemplo del RFC 9001
quic_initial.py --catch nombre.prueba hace que curl intente HTTP/3 contra un socket propio
quic_initial.py --pcap archivo.pcap lee los Initial del cliente en una captura
AES viene del comando openssl, y no se verifica la etiqueta de autenticación de GCM: aquí solo
interesa mostrar que el contenido se puede leer.
"""
import argparse
import hashlib
import hmac
import socket
import struct
import subprocess
import sys
INITIAL_SALT_V1 = bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a")
TAG_LEN = 16
def hkdf_extract(salt, ikm):
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand_label(secret, label, length):
"""HKDF-Expand-Label de TLS 1.3 con contexto vacío (RFC 8446, sec. 7.1)."""
full = b"tls13 " + label.encode()
info = struct.pack("!HB", length, len(full)) + full + b"\x00"
output, block, counter = b"", b"", 1
while len(output) < length:
block = hmac.new(secret, block + info + bytes([counter]), hashlib.sha256).digest()
output += block
counter += 1
return output[:length]
def client_initial_keys(dcid):
"""Devuelve (key, iv, hp): todo sale del DCID y de una sal pública."""
initial = hkdf_extract(INITIAL_SALT_V1, dcid)
secret = hkdf_expand_label(initial, "client in", 32)
return (hkdf_expand_label(secret, "quic key", 16), hkdf_expand_label(secret, "quic iv", 12),
hkdf_expand_label(secret, "quic hp", 16))
def openssl_enc(mode, key, iv, data):
args = ["openssl", "enc", mode, "-K", key.hex(), "-nosalt"]
if iv is not None:
args += ["-iv", iv.hex()]
if mode.endswith("ecb"):
args.append("-nopad")
return subprocess.run(args, input=data, capture_output=True, check=True).stdout
def read_varint(data, pos):
"""Enteros de largo variable de QUIC: los 2 bits altos dicen cuántos bytes ocupan."""
length = 1 << (data[pos] >> 6)
value = data[pos] & 0x3f
for byte in data[pos + 1:pos + length]:
value = value << 8 | byte
return value, pos + length
def unprotect_initial(packet):
"""Quita la protección de un Initial del cliente y devuelve sus datos y sus frames."""
first = packet[0]
if first & 0xf0 != 0xc0:
raise ValueError("no es un Initial con encabezado largo")
version = int.from_bytes(packet[1:5], "big")
if version != 1:
raise ValueError(f"versión de QUIC desconocida: {version:#x}")
pos = 5
dcid = packet[pos + 1:pos + 1 + packet[pos]]
pos += 1 + packet[pos]
scid = packet[pos + 1:pos + 1 + packet[pos]]
pos += 1 + packet[pos]
token_len, pos = read_varint(packet, pos)
pos += token_len
length, pos = read_varint(packet, pos)
key, iv, hp = client_initial_keys(dcid)
# La muestra para la máscara empieza 4 bytes después del inicio del número de paquete.
sample = packet[pos + 4:pos + 4 + 16]
mask = openssl_enc("-aes-128-ecb", hp, None, sample)
first_clear = first ^ (mask[0] & 0x0f)
pn_len = (first_clear & 0x03) + 1
pn_bytes = bytes(b ^ m for b, m in zip(packet[pos:pos + pn_len], mask[1:]))
packet_number = int.from_bytes(pn_bytes, "big")
# AES-GCM con nonce de 96 bits cifra en modo contador, y el primer bloque de datos usa el
# contador 2. Por eso se puede descifrar con AES-CTR, si no se revisa la etiqueta.
nonce = (int.from_bytes(iv, "big") ^ packet_number).to_bytes(12, "big")
body = packet[pos + pn_len:pos + length - TAG_LEN]
frames = openssl_enc("-aes-128-ctr", key, nonce + b"\x00\x00\x00\x02", body)
return {"dcid": dcid, "scid": scid, "packet_number": packet_number, "frames": frames,
"header_len": pos + pn_len, "size": len(packet)}
def crypto_frames(frames):
"""Recorre los frames: PADDING, PING, ACK y CRYPTO son los únicos permitidos aquí."""
pieces = []
pos = 0
while pos < len(frames):
kind = frames[pos]
if kind == 0x00:
pos += 1
elif kind == 0x01:
pos += 1
elif kind == 0x06:
offset, pos = read_varint(frames, pos + 1)
length, pos = read_varint(frames, pos)
pieces.append((offset, frames[pos:pos + length]))
pos += length
else:
raise ValueError(f"frame inesperado en un Initial: {kind:#x}")
return pieces
def reassemble(pieces):
"""Une los pedazos del flujo CRYPTO por offset: un ClientHello puede ocupar varios paquetes."""
data = bytearray()
for offset, piece in sorted(pieces):
if offset > len(data):
break
data[offset:offset + len(piece)] = piece
return bytes(data)
def client_hello_names(hello):
"""Devuelve (SNI, lista ALPN) de un ClientHello sin encabezado de registro."""
if len(hello) < 4 or hello[0] != 1:
raise ValueError("no es un ClientHello")
pos = 4 + 2 + 32
pos += 1 + hello[pos]
pos += 2 + int.from_bytes(hello[pos:pos + 2], "big")
pos += 1 + hello[pos]
end = pos + 2 + int.from_bytes(hello[pos:pos + 2], "big")
pos += 2
sni, alpn = None, []
while pos + 4 <= end:
ext = int.from_bytes(hello[pos:pos + 2], "big")
size = int.from_bytes(hello[pos + 2:pos + 4], "big")
body = hello[pos + 4:pos + 4 + size]
if ext == 0:
sni = body[5:5 + int.from_bytes(body[3:5], "big")].decode()
elif ext == 16:
at = 2
while at < len(body):
alpn.append(body[at + 1:at + 1 + body[at]].decode())
at += 1 + body[at]
pos += 4 + size
return sni, alpn
def describe(packets):
pieces = []
for number, packet in enumerate(packets):
info = unprotect_initial(packet)
found = crypto_frames(info["frames"])
pieces += found
print(f"datagrama {number}: {info['size']} bytes, DCID={info['dcid'].hex()}, "
f"número de paquete {info['packet_number']}, "
f"bytes de CRYPTO: {sum(len(piece) for _, piece in found)}")
hello = reassemble(pieces)
sni, alpn = client_hello_names(hello)
print(f"ClientHello de {len(hello)} bytes")
print(f"SNI: {sni}")
print(f"ALPN: {' '.join(alpn)}")
return sni, alpn
def catch(name, wait=2.0):
"""Abre un socket UDP y le pide a curl HTTP/3 contra él. Nadie contesta, y no hace falta."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.settimeout(wait)
curl = subprocess.Popen(
["curl", "-s", "-k", "--http3-only", "--max-time", "2", "--resolve",
f"{name}:{port}:127.0.0.1", f"https://{name}:{port}/"])
try:
packets = [sock.recv(2048)]
sock.settimeout(0.3)
# Sale cuando vuelve a llegar el mismo paquete: es una retransmisión.
while True:
packet = sock.recv(2048)
if packet[0] & 0xf0 != 0xc0 or packet in packets:
break
info = unprotect_initial(packet)
numbers = [unprotect_initial(old)["packet_number"] for old in packets]
if info["packet_number"] in numbers:
break
packets.append(packet)
except socket.timeout:
pass
finally:
curl.kill()
curl.wait()
return packets
def from_pcap(path):
"""Saca de una captura los datagramas Initial del cliente, con tshark."""
done = subprocess.run(
["tshark", "-r", path, "-Y", "udp.dstport == 443", "-T", "fields", "-e", "udp.payload"],
capture_output=True, text=True, check=True)
packets = []
for line in done.stdout.split():
packet = bytes.fromhex(line)
if packet[0] & 0xf0 != 0xc0:
continue
# Las claves salen del primer DCID. Cuando el servidor contesta, el cliente pasa a usar
# el ID que eligió el servidor: esos paquetes ya no se abren con esta cuenta.
if packets and packet[6:6 + packet[5]] != packets[0][6:6 + packets[0][5]]:
break
packets.append(packet)
return packets
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--vector", action="store_true")
parser.add_argument("--catch", metavar="NOMBRE")
parser.add_argument("--pcap", metavar="ARCHIVO")
args = parser.parse_args()
if args.vector:
key, iv, hp = client_initial_keys(bytes.fromhex("8394c8f03e515708"))
print("DCID", "8394c8f03e515708")
print("key ", key.hex())
print("iv ", iv.hex())
print("hp ", hp.hex())
elif args.catch:
describe(catch(args.catch))
elif args.pcap:
describe(from_pcap(args.pcap))
else:
parser.print_help()
return 2
return 0
if __name__ == "__main__":
sys.exit(main())c000000001088394c8f03e5157080000449e7b9aec34d1b1c98dd7689fb8ec11d242b123dc9bd8bab936b47d92ec356c0bab7df5976d27cd449f63300099f3991c260ec4c60d17b31f8429157bb35a1282a643a8d2262cad67500cadb8e7378c8eb7539ec4d4905fed1bee1fc8aafba17c750e2c7ace01e6005f80fcb7df621230c83711b39343fa028cea7f7fb5ff89eac2308249a02252155e2347b63d58c5457afd84d05dfffdb20392844ae812154682e9cf012f9021a6f0be17ddd0c2084dce25ff9b06cde535d0f920a2db1bf362c23e596d11a4f5a6cf3948838a3aec4e15daf8500a6ef69ec4e3feb6b1d98e610ac8b7ec3faf6ad760b7bad1db4ba3485e8a94dc250ae3fdb41ed15fb6a8e5eba0fc3dd60bc8e30c5c4287e53805db059ae0648db2f64264ed5e39be2e20d82df566da8dd5998ccabdae053060ae6c7b4378e846d29f37ed7b4ea9ec5d82e7961b7f25a9323851f681d582363aa5f89937f5a67258bf63ad6f1a0b1d96dbd4faddfcefc5266ba6611722395c906556be52afe3f565636ad1b17d508b73d8743eeb524be22b3dcbc2c7468d54119c7468449a13d8e3b95811a198f3491de3e7fe942b330407abf82a4ed7c1b311663ac69890f4157015853d91e923037c227a33cdd5ec281ca3f79c44546b9d90ca00f064c99e3dd97911d39fe9c5d0b23a229a234cb36186c4819e8b9c5927726632291d6a418211cc2962e20fe47feb3edf330f2c603a9d48c0fcb5699dbfe5896425c5bac4aee82e57a85aaf4e2513e4f05796b07ba2ee47d80506f8d2c25e50fd14de71e6c418559302f939b0e1abd576f279c4b2e0feb85c1f28ff18f58891ffef132eef2fa09346aee33c28eb130ff28f5b766953334113211996d20011a198e3fc433f9f2541010ae17c1bf202580f6047472fb36857fe843b19f5984009ddc324044e847a4f4a0ab34f719595de37252d6235365e9b84392b061085349d73203a4a13e96f5432ec0fd4a1ee65accdd5e3904df54c1da510b0ff20dcc0c77fcb2c0e0eb605cb0504db87632cf3d8b4dae6e705769d1de354270123cb11450efc60ac47683d7b8d0f811365565fd98c4c8eb936bcab8d069fc33bd801b03adea2e1fbc5aa463d08ca19896d2bf59a071b851e6c239052172f296bfb5e72404790a2181014f3b94a4e97d117b438130368cc39dbb2d198065ae3986547926cd2162f40a29f0c3c8745c0f50fba3852e566d44575c29d39a03f0cda721984b6f440591f355e12d439ff150aab7613499dbd49adabc8676eef023b15b65bfc5ca06948109f23f350db82123535eb8a7433bdabcb909271a6ecbcb58b936a88cd4e8f2e6ff5800175f113253d8fa9ca8885c2f552e657dc603f252e1a8e308f76f0be79e2fb8f5d5fbbe2e30ecadd220723c8c0aea8078cdfcb3868263ff8f0940054da48781893a7e49ad5aff4af300cd804a6b6279ab3ff3afb64491c85194aab760d58a606654f9f4400e8b38591356fbf6425aca26dc85244259ff2b19c41b9f96f3ca9ec1dde434da7d2d392b905ddf3d1f9af93d1af5950bd493f5aa731b4056df31bd267b6b90a079831aaf579be0a39013137aac6d404f518cfd46840647e78bfe706ca4cf5e9c5453e9f7cfd2b8b4c8d169a44e55c88d4a9a7f9474241e221af44860018ab0856972e194cd934#!/usr/bin/env python3
"""Los experimentos de TLS de los apuntes, sobre la red c - mb - s (net/net.clab.yml).
Se corren en el contenedor del curso, con la topología arriba (make up):
python3 scenarios.py rtts cuenta RTT: TLS 1.3 frente a TLS 1.2
python3 scenarios.py observer lo que ve mb del handshake, sin claves
python3 scenarios.py keylog la misma captura, con SSLKEYLOGFILE
python3 scenarios.py peek sni_peek: el ClientHello sin ninguna clave
python3 scenarios.py certs los errores de curl con cada certificado
python3 scenarios.py migration UDP con connection ID frente a TCP, al cambiar de dirección
python3 scenarios.py dash [abr] un cliente DASH real, con una caída del enlace a la mitad
"""
import os
import shutil
import statistics
import sys
import threading
import time
import make_dash
from netlab import HERE, Lab, make_pki, wait_until
NET = Lab("net", "w13-net", "redes-w13-net")
SERVER = "10.213.2.10"
NAME = "demo.redes.test"
PORT = 443
RTT_MS = 40
RESOLVE = f"{NAME}:{PORT}:{SERVER}"
URL = f"https://{NAME}:{PORT}/"
TIMES = "%{time_connect} %{time_appconnect} %{time_starttransfer} %{ssl_verify_result}"
def start_server(cert="good", port=PORT, extra=""):
"""openssl s_server -www contesta cualquier GET con una página de estado."""
NET.sh("s", "pkill", "-f", "openssl s_server")
NET.daemon("s", f"cd /w13/build/pki && exec openssl s_server -accept {port} -www "
f"-cert {cert}.crt -key {cert}.key {extra} >/tmp/server.log 2>&1 </dev/zero")
time.sleep(0.5)
def stop_server():
NET.sh("s", "pkill", "-f", "openssl s_server")
def curl(*args):
return NET.sh("c", "curl", "-s", "--max-time", "5", "--resolve", RESOLVE,
"--cacert", "/w13/build/pki/ca.crt", *args, URL)
def measure(*args, rounds=7):
"""Medianas de cada fase, en ms. curl abre una conexión nueva cada vez."""
samples = []
for _ in range(rounds):
done = curl("-o", "/dev/null", "-w", TIMES, *args)
parts = done.stdout.split()
samples.append([float(value) * 1000 for value in parts[:3]])
return [statistics.median(column) for column in zip(*samples)]
def rtts():
start_server()
print(f"RTT de la red: {RTT_MS} ms (retardo de netem en mb). Medianas de 7 conexiones, en ms.")
print(f"{'':10}{'TCP listo':>10}{'TLS listo':>11}{'primer byte':>13}{'RTT del TLS':>13}{'RTT al byte':>13}")
for title, flags in (("TLS 1.3", ["--tlsv1.3"]), ("TLS 1.2", ["--tlsv1.2", "--tls-max", "1.2"])):
tcp, tls, first = measure(*flags)
print(f"{title:10}{tcp:10.1f}{tls:11.1f}{first:13.1f}{(tls - tcp) / RTT_MS:13.1f}{first / RTT_MS:13.1f}")
stop_server()
def capture(name, node="mb", device="eth1"):
"""Corre una petición mientras mb captura, y devuelve la ruta dentro del contenedor del curso."""
path = f"/w13/captures/{name}.pcap"
NET.sh(node, "rm", "-f", path)
NET.daemon(node, f"tcpdump -i {device} -n -U -s0 -w {path} tcp port {PORT} >/dev/null 2>&1")
# El archivo aparece cuando tcpdump ya abrió la interfaz. Docker puede tardar en lanzarlo.
wait_until(lambda: NET.sh(node, "test", "-s", path).returncode == 0, seconds=15, step=0.2)
time.sleep(0.3)
return path
def stop_capture(node="mb"):
time.sleep(0.8)
NET.sh(node, "pkill", "tcpdump")
time.sleep(0.4)
def tshark(path, *args):
"""tshark corre en c, que ve la misma carpeta en /w13."""
return NET.sh("c", "tshark", "-r", path, *args).stdout
def observer_capture():
start_server()
path = capture("handshake")
curl("--tlsv1.3", "-o", "/dev/null")
stop_capture()
stop_server()
return path
def observer():
path = observer_capture()
print("Lo que ve mb, sin claves:")
print(tshark(path, "-Y", "tls", "-o", "tls.keylog_file:", "-T", "fields",
"-e", "frame.number", "-e", "ip.src", "-e", "_ws.col.Info").rstrip())
print("\nEl nombre del servidor, leído sin claves:")
print(tshark(path, "-Y", "tls.handshake.type == 1", "-T", "fields",
"-e", "tls.handshake.extensions_server_name").rstrip())
print("\nMensajes de certificado que se pueden leer:", tshark(path, "-Y", "tls.handshake.type == 11").count("Certificate"))
def keylog():
NET.sh("c", "rm", "-f", "/w13/captures/keys.log")
start_server()
path = capture("keylog")
NET.sh("c", "sh", "-c",
f"SSLKEYLOGFILE=/w13/captures/keys.log curl -s --max-time 5 --tlsv1.3 --http1.1 --resolve {RESOLVE} "
f"--cacert /w13/build/pki/ca.crt -o /dev/null {URL}")
stop_capture()
stop_server()
print("Etiquetas del archivo de claves:")
print(NET.out("c", "sh", "-c", "cut -d' ' -f1 /w13/captures/keys.log | sort").rstrip())
print("\nLa misma captura, ahora con las claves:")
print(tshark(path, "-Y", "tls", "-o", "tls.keylog_file:/w13/captures/keys.log", "-T", "fields",
"-e", "frame.number", "-e", "ip.src", "-e", "_ws.col.Info").rstrip())
print("\nLa petición, en claro:")
print(tshark(path, "-Y", "http.request", "-o", "tls.keylog_file:/w13/captures/keys.log", "-T", "fields",
"-e", "http.request.method", "-e", "http.request.uri", "-e", "http.host").rstrip())
def peek():
NET.daemon("s", f"exec /w13/bin/sni_peek {PORT} 1 >/tmp/peek.log 2>&1")
time.sleep(0.5)
NET.sh("c", "curl", "-s", "--max-time", "2", "--resolve", RESOLVE, "-k", URL)
time.sleep(0.3)
print(NET.out("s", "cat", "/tmp/peek.log").rstrip())
def first_line(text):
return text.strip().splitlines()[0] if text.strip() else ""
def certs():
print(f"{'certificado':14}{'salida':>7} mensaje de curl")
for cert in ("good", "selfsigned", "wrongname", "expired"):
start_server(cert)
done = NET.sh("c", "curl", "-sS", "--max-time", "5", "--resolve", RESOLVE,
"--cacert", "/w13/build/pki/ca.crt", "-o", "/dev/null", URL)
print(f"{cert:14}{done.returncode:>7} {first_line(done.stderr) or 'bien'}")
print("\nCon la CA del sistema, sin la CA de la clase:")
start_server("good")
done = NET.sh("c", "curl", "-sS", "--max-time", "5", "--resolve", RESOLVE, "-o", "/dev/null", URL)
print(f"{'good':14}{done.returncode:>7} {first_line(done.stderr)}")
stop_server()
def migration():
"""Primero TCP, luego UDP: cada corrida empieza en la red original y termina en la otra."""
NET.sh("s", "pkill", "-f", "cid_echo")
NET.daemon("s", "exec python3 -u /w13/cid_echo.py serve 7000 >/tmp/cid.log 2>&1")
time.sleep(0.7)
for title, kind in (("TCP", "tcp"), ("UDP con connection ID", "udp")):
print(f"== {title}")
print(NET.out("c", "python3", "/w13/cid_echo.py", kind, SERVER, "7000").rstrip(), "\n")
NET.sh("c", "python3", "/w13/cid_echo.py", "restore")
print("== Lo que anotó el servidor")
print(NET.out("s", "cat", "/tmp/cid.log").rstrip())
NET.sh("s", "pkill", "-f", "cid_echo")
def link(rate):
"""Cambia la velocidad hacia el cliente. La cola queda limitada, para no medir bufferbloat."""
NET.sh("mb", "tc", "qdisc", "change", "dev", "eth1", "root", "netem", "delay", "20ms",
"rate", rate, "limit", "120")
def link_reset():
NET.sh("mb", "tc", "qdisc", "change", "dev", "eth1", "root", "netem", "delay", "20ms")
def dash_run(strategy, segments=25):
"""6 Mbit/s, luego 1,2 Mbit/s por 12 s, luego 6 Mbit/s otra vez. Devuelve lo que imprime el cliente."""
folder = os.path.join(HERE, "build", "dash")
if not os.path.exists(os.path.join(folder, "400k", f"seg-{segments - 1}.m4s")):
shutil.rmtree(folder, ignore_errors=True)
make_dash.build(folder, segments, 2)
NET.sh("s", "pkill", "-f", "http.server")
NET.daemon("s", "exec python3 -m http.server 8000 --directory /w13/build/dash >/tmp/http.log 2>&1")
time.sleep(1.0)
link("6mbit")
cuts = threading.Timer(10, link, args=("1200kbit",))
back = threading.Timer(22, link, args=("6mbit",))
cuts.start()
back.start()
try:
return NET.out("c", "python3", "/w13/dash_client.py", f"http://{SERVER}:8000/video.mpd", strategy,
timeout=180)
finally:
cuts.cancel()
back.cancel()
cuts.join()
back.join()
link_reset()
NET.sh("s", "pkill", "-f", "http.server")
def dash():
strategy = sys.argv[2] if len(sys.argv) > 2 else "last"
# El cliente corre en c, donde esta carpeta se ve como /w13.
if strategy.endswith(".py"):
strategy = "/w13/" + os.path.relpath(strategy, HERE)
print(dash_run(strategy).rstrip())
COMMANDS = {"dash": dash, "rtts": rtts, "observer": observer, "keylog": keylog, "peek": peek, "certs": certs,
"migration": migration}
def main():
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print(__doc__)
return 2
make_pki()
COMMANDS[sys.argv[1]]()
return 0
if __name__ == "__main__":
sys.exit(main())// Lee el primer mensaje de un cliente TLS sin saber nada de TLS más allá del formato, y lo
// muestra. No tiene clave ni certificado: ve lo mismo que cualquier middlebox en el camino.
//
// sni_peek <puerto> [conexiones]
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#define MAX_HELLO 16384
#define RECORD_HEADER 5
#define HANDSHAKE_HEADER 4
#define TYPE_HANDSHAKE 22
#define HANDSHAKE_CLIENT_HELLO 1
#define EXT_SERVER_NAME 0
#define EXT_ALPN 16
#define EXT_SUPPORTED_VERSIONS 43
#define EXT_KEY_SHARE 51
// Un cursor sobre bytes recibidos: toda lectura revisa primero que quede espacio.
struct cursor {
const uint8_t *data;
size_t left;
};
static int read_u8(struct cursor *c, unsigned *value) {
if (c->left < 1) return -1;
*value = c->data[0];
c->data += 1;
c->left -= 1;
return 0;
}
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;
}
static int skip(struct cursor *c, size_t n) {
if (c->left < n) return -1;
c->data += n;
c->left -= n;
return 0;
}
// Saca un pedazo de n bytes, con su propio límite.
static int slice(struct cursor *c, size_t n, struct cursor *part) {
if (c->left < n) return -1;
part->data = c->data;
part->left = n;
return skip(c, n);
}
// Lo que manda el cliente no es de fiar: nada de bytes de control hacia la terminal.
static void print_text(const uint8_t *data, size_t len) {
for (size_t i = 0; i < len; i++) {
if (data[i] >= 0x20 && data[i] < 0x7f) putchar(data[i]);
else printf("\\x%02x", data[i]);
}
}
static int show_server_name(struct cursor ext) {
unsigned list_len, name_type, name_len;
if (read_u16(&ext, &list_len) < 0 || read_u8(&ext, &name_type) < 0 ||
read_u16(&ext, &name_len) < 0 || ext.left < name_len)
return -1;
printf(" SNI: ");
print_text(ext.data, name_len);
printf("\n");
return 0;
}
static int show_alpn(struct cursor ext) {
unsigned list_len, len;
if (read_u16(&ext, &list_len) < 0) return -1;
printf(" ALPN:");
while (ext.left > 0) {
if (read_u8(&ext, &len) < 0 || ext.left < len) return -1;
printf(" ");
print_text(ext.data, len);
if (skip(&ext, len) < 0) return -1;
}
printf("\n");
return 0;
}
static int show_versions(struct cursor ext) {
unsigned list_len, version;
if (read_u8(&ext, &list_len) < 0) return -1;
printf(" versiones:");
while (ext.left > 0) {
if (read_u16(&ext, &version) < 0) return -1;
if (version == 0x0304) printf(" TLS1.3");
else if (version == 0x0303) printf(" TLS1.2");
else printf(" 0x%04x", version);
}
printf("\n");
return 0;
}
static int show_key_shares(struct cursor ext) {
unsigned list_len, group, len;
if (read_u16(&ext, &list_len) < 0) return -1;
printf(" key_share:");
while (ext.left > 0) {
if (read_u16(&ext, &group) < 0 || read_u16(&ext, &len) < 0 || skip(&ext, len) < 0)
return -1;
printf(" grupo 0x%04x (%u bytes)", group, len);
}
printf("\n");
return 0;
}
// Devuelve 0 si pudo leer el ClientHello completo, -1 si está mal formado o incompleto.
static int parse_client_hello(const uint8_t *buf, size_t len) {
struct cursor msg = {buf, len};
struct cursor extensions;
unsigned type, major, minor, length, hs_high, hs_low, id_len, suites_len, comp_len, ext_len;
if (read_u8(&msg, &type) < 0 || type != TYPE_HANDSHAKE) return -1;
if (read_u8(&msg, &major) < 0 || read_u8(&msg, &minor) < 0) return -1;
if (read_u16(&msg, &length) < 0 || msg.left < length) return -1;
if (read_u8(&msg, &type) < 0 || type != HANDSHAKE_CLIENT_HELLO) return -1;
if (read_u8(&msg, &hs_high) < 0 || read_u16(&msg, &hs_low) < 0) return -1;
printf(" registro: tipo 22 (handshake), versión %u.%u, %u bytes\n", major, minor, length);
if (skip(&msg, 2 + 32) < 0) return -1; // legacy_version y random
if (read_u8(&msg, &id_len) < 0 || skip(&msg, id_len) < 0) return -1;
if (read_u16(&msg, &suites_len) < 0 || skip(&msg, suites_len) < 0) return -1;
printf(" cipher suites ofrecidas: %u\n", suites_len / 2);
if (read_u8(&msg, &comp_len) < 0 || skip(&msg, comp_len) < 0) return -1;
if (read_u16(&msg, &ext_len) < 0 || slice(&msg, ext_len, &extensions) < 0) return -1;
while (extensions.left > 0) {
unsigned ext_type, body_len;
struct cursor body;
int status = 0;
if (read_u16(&extensions, &ext_type) < 0 || read_u16(&extensions, &body_len) < 0 ||
slice(&extensions, body_len, &body) < 0)
return -1;
if (ext_type == EXT_SERVER_NAME) status = show_server_name(body);
else if (ext_type == EXT_ALPN) status = show_alpn(body);
else if (ext_type == EXT_SUPPORTED_VERSIONS) status = show_versions(body);
else if (ext_type == EXT_KEY_SHARE) status = show_key_shares(body);
if (status < 0) return -1;
}
return 0;
}
// Lee un registro TLS entero: primero 5 bytes, y ahí dice cuánto falta.
static ssize_t read_record(int fd, uint8_t *buf, size_t size) {
size_t have = 0;
size_t want = RECORD_HEADER;
while (have < want) {
ssize_t got = recv(fd, buf + have, want - have, 0);
if (got < 0) {
if (errno == EINTR) continue;
return -1;
}
if (got == 0) return (ssize_t)have;
have += (size_t)got;
if (have >= RECORD_HEADER && want == RECORD_HEADER) {
size_t total = RECORD_HEADER + (((size_t)buf[3] << 8) | buf[4]);
if (total > size) return (ssize_t)have;
want = total;
}
}
return (ssize_t)have;
}
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "uso: %s <puerto> [conexiones]\n", argv[0]);
return 2;
}
int port = atoi(argv[1]);
int count = argc > 2 ? atoi(argv[2]) : -1;
if (port <= 0 || port > 65535) {
fprintf(stderr, "puerto inválido: %s\n", argv[1]);
return 2;
}
int listener = socket(AF_INET, SOCK_STREAM, 0);
if (listener < 0) {
perror("socket");
return 1;
}
int one = 1;
if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) < 0) {
perror("setsockopt");
return 1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof addr);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons((uint16_t)port);
if (bind(listener, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("bind");
return 1;
}
if (listen(listener, 8) < 0) {
perror("listen");
return 1;
}
printf("escuchando en el puerto %d\n", port);
fflush(stdout);
static uint8_t buf[RECORD_HEADER + MAX_HELLO];
for (int served = 0; count < 0 || served < count; served++) {
struct sockaddr_in peer;
socklen_t peer_len = sizeof peer;
int fd = accept(listener, (struct sockaddr *)&peer, &peer_len);
if (fd < 0) {
if (errno == EINTR) continue;
perror("accept");
return 1;
}
// Un cliente que no manda nada no debe trabar el programa.
struct timeval wait = {.tv_sec = 2, .tv_usec = 0};
if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &wait, sizeof wait) < 0) perror("setsockopt");
char text[INET_ADDRSTRLEN];
if (inet_ntop(AF_INET, &peer.sin_addr, text, sizeof text) == NULL) {
perror("inet_ntop");
text[0] = '\0';
}
printf("conexión de %s\n", text);
ssize_t got = read_record(fd, buf, sizeof buf);
if (got < 0) perror("recv");
else if (parse_client_hello(buf, (size_t)got) < 0) printf(" no es un ClientHello completo\n");
fflush(stdout);
if (close(fd) < 0) perror("close");
}
if (close(listener) < 0) perror("close");
return 0;
}#!/usr/bin/env python3
"""El riesgo de 0-RTT con TLS 1.3 real: alguien graba el primer vuelo y lo manda otra vez.
Un cliente reanuda una sesión con openssl s_client y manda una petición como early data. Pasa
por un relay que guarda lo que ve, sin poder leerlo. Después el relay envía esos mismos bytes
al servidor en otra conexión. Se cuenta cuántas veces el servidor procesó la petición:
con la protección de OpenSSL (por defecto), una; con -no_anti_replay, dos.
Uso: zero_rtt.py <carpeta con good.crt y good.key>
"""
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
REQUEST = b"POST /transferir?monto=100 HTTP/1.0\r\n\r\n"
MARK = b"POST /transferir"
def free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def relay(listener, target, recorded, seconds):
"""Pasa bytes en los dos sentidos y guarda lo que manda el cliente."""
client, _ = listener.accept()
server = socket.create_connection(("127.0.0.1", target))
client.settimeout(0.2)
server.settimeout(0.2)
deadline = time.monotonic() + seconds
try:
while time.monotonic() < deadline:
for source, sink, keep in ((client, server, True), (server, client, False)):
try:
data = source.recv(65536)
except socket.timeout:
continue
if data:
if keep:
recorded.append(data)
sink.sendall(data)
except OSError:
pass
finally:
client.close()
server.close()
def trial(pki, flags):
"""Devuelve (lo que el cliente oyó del early data, veces antes de repetir, veces después)."""
port, relay_port = free_port(), free_port()
with tempfile.TemporaryDirectory() as tmp:
session = os.path.join(tmp, "session.pem")
early = os.path.join(tmp, "early.txt")
log_path = os.path.join(tmp, "server.log")
with open(early, "wb") as file:
file.write(REQUEST)
with open(log_path, "wb") as log:
# Sin -www: el modo de eco de s_server es el que lee early data.
server = subprocess.Popen(
["openssl", "s_server", "-accept", str(port), "-tls1_3", "-early_data",
"-cert", os.path.join(pki, "good.crt"), "-key", os.path.join(pki, "good.key"), *flags],
stdin=subprocess.PIPE, stdout=log, stderr=log)
try:
# s_server atiende de a uno: probar el puerto contaría como cliente. Se espera.
time.sleep(0.5)
if server.poll() is not None:
raise RuntimeError(f"s_server terminó (puerto {port})")
return run_clients(port, relay_port, session, early, log_path)
finally:
server.kill()
server.wait()
def run_clients(port, relay_port, session, early, log_path):
# Primera visita: solo sirve para recibir el ticket. Se cierra bien, o el ticket se invalida.
first = subprocess.Popen(
["openssl", "s_client", "-connect", f"127.0.0.1:{port}", "-tls1_3", "-sess_out", session],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.8)
first.stdin.write(b"Q\n")
first.stdin.close()
first.wait(timeout=5)
time.sleep(0.2)
listener = socket.create_server(("127.0.0.1", relay_port))
listener.settimeout(5)
recorded = []
middle = threading.Thread(target=relay, args=(listener, port, recorded, 1.5))
middle.start()
client = subprocess.Popen(
["openssl", "s_client", "-connect", f"127.0.0.1:{relay_port}", "-tls1_3", "-sess_in", session,
"-early_data", early],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(1.2)
client.kill()
output = client.communicate()[0]
middle.join()
listener.close()
heard = [line for line in output.splitlines() if "Early data" in line]
time.sleep(0.3)
before = open(log_path, "rb").read().count(MARK)
# La repetición: los mismos bytes, en otra conexión, sin saber ninguna clave.
with socket.create_connection(("127.0.0.1", port)) as attacker:
attacker.sendall(recorded[0])
time.sleep(0.7)
time.sleep(0.3)
after = open(log_path, "rb").read().count(MARK)
return (heard[0] if heard else "sin respuesta sobre early data"), before, after
def main():
pki = sys.argv[1] if len(sys.argv) > 1 else "build/pki"
for title, flags in (("protección por defecto", []), ("-no_anti_replay", ["-no_anti_replay"])):
heard, before, after = trial(pki, flags)
print(f"== {title}")
print(f"cliente legítimo: {heard}")
print(f"veces que el servidor procesó la petición: {before} antes de repetir, {after} después")
if __name__ == "__main__":
main()# Una CDN mínima: dos regiones unidas por un enlace largo, con una réplica en cada una.
#
# 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.
#
# s1 y s2 anuncian el mismo prefijo, 10.213.50.0/24: es anycast. Además cada una tiene su dirección
# de unicast (10.213.21.10 y 10.213.22.10), que es la que devuelve el DNS.
name: w13-cdn
mgmt:
network: redes-w13-cdn
ipv4-subnet: 172.90.13.64/26
ipv6-subnet: 3fff:172:90:13:2::/80
topology:
defaults:
kind: linux
image: redes-router
nodes:
r1:
binds: [r1/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- tc qdisc add dev eth1 root netem delay 30ms
r2:
binds: [r2/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- tc qdisc add dev eth1 root netem delay 30ms
s1:
binds: [s1/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.50.1/24 dev lo
- mkdir -p /www
- sh -c "echo réplica s1 > /www/quien.txt"
- sh -c "head -c 20000000 /dev/zero > /www/grande.bin"
s2:
binds: [s2/frr.conf:/etc/frr/frr.conf:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.50.1/24 dev lo
- mkdir -p /www
- sh -c "echo réplica s2 > /www/quien.txt"
- sh -c "head -c 20000000 /dev/zero > /www/grande.bin"
c1:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.11.10/24 dev eth1
- ip route add default via 10.213.11.1
c2:
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.12.10/24 dev eth1
- ip route add default via 10.213.12.1
ns:
binds: [../geo_dns.py:/geo_dns.py:ro]
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.30.10/24 dev eth1
- ip route add default via 10.213.30.1
links:
- endpoints: ["r1:eth1", "r2:eth1"]
mtu: 1500
- endpoints: ["r1:eth2", "c1:eth1"]
mtu: 1500
- endpoints: ["r1:eth3", "s1:eth1"]
mtu: 1500
- endpoints: ["r1:eth4", "ns:eth1"]
mtu: 1500
- endpoints: ["r2:eth2", "c2:eth1"]
mtu: 1500
- endpoints: ["r2:eth3", "s2:eth1"]
mtu: 1500# Un cliente, un middlebox y un servidor. mb agrega retardo en sus dos interfaces y es el lugar
# natural para capturar: ve lo mismo que cualquier equipo entre el cliente y el servidor.
#
# c 10.213.1.10 ---- eth1 mb eth2 ---- 10.213.2.10 s
#
# mb tiene otra red en eth1, 10.213.3.0/24: a ella se muda c en la demostración de migración.
# c y s usan la imagen del contenedor de trabajo, porque tienen openssl y curl con HTTP/3.
# mb usa la imagen de los routers, sin demonios de routing: basta con forwarding y tc.
# Toda la carpeta de la semana queda en /w13, y captures/ guarda los .pcap.
name: w13-net
mgmt:
network: redes-w13-net
ipv4-subnet: 172.90.13.0/26
ipv6-subnet: 3fff:172:90:13:1::/80
topology:
defaults:
kind: linux
binds:
- ..:/w13
nodes:
c:
image: redes
cmd: sleep infinity
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.1.10/24 dev eth1
- ip route add default via 10.213.1.1
s:
image: redes
cmd: sleep infinity
exec:
- ip route del default
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- ip addr add 10.213.2.10/24 dev eth1
- ip route add default via 10.213.2.1
mb:
image: redes-router
exec:
- sysctl -w net.ipv6.conf.all.disable_ipv6=1
- sysctl -w net.ipv4.ip_forward=1
- ip addr add 10.213.1.1/24 dev eth1
- ip addr add 10.213.3.1/24 dev eth1
- ip addr add 10.213.2.1/24 dev eth2
- tc qdisc add dev eth1 root netem delay 20ms
- tc qdisc add dev eth2 root netem delay 20ms
links:
- endpoints: ["c:eth1", "mb:eth1"]
mtu: 1500
- endpoints: ["mb:eth2", "s:eth1"]
mtu: 1500frr defaults traditional
hostname r1
interface eth1
ip address 10.213.100.1/30
interface eth2
ip address 10.213.11.1/24
interface eth3
ip address 10.213.21.1/24
interface eth4
ip address 10.213.30.1/24
! Aquí no interesan las políticas, sino qué camino gana: basta con el AS path más corto.
router bgp 65301
bgp router-id 10.213.100.1
no bgp ebgp-requires-policy
timers bgp 1 3
neighbor 10.213.100.2 remote-as 65302
neighbor 10.213.100.2 advertisement-interval 0
neighbor 10.213.21.10 remote-as 65310
neighbor 10.213.21.10 advertisement-interval 0
address-family ipv4 unicast
network 10.213.11.0/24
network 10.213.21.0/24
network 10.213.30.0/24frr defaults traditional
hostname r2
interface eth1
ip address 10.213.100.2/30
interface eth2
ip address 10.213.12.1/24
interface eth3
ip address 10.213.22.1/24
router bgp 65302
bgp router-id 10.213.100.2
no bgp ebgp-requires-policy
timers bgp 1 3
neighbor 10.213.100.1 remote-as 65301
neighbor 10.213.100.1 advertisement-interval 0
neighbor 10.213.22.10 remote-as 65310
neighbor 10.213.22.10 advertisement-interval 0
address-family ipv4 unicast
network 10.213.12.0/24
network 10.213.22.0/24frr defaults traditional
hostname s1
interface eth1
ip address 10.213.21.10/24
! Las dos réplicas anuncian el mismo prefijo, desde el mismo AS.
router bgp 65310
bgp router-id 10.213.21.10
no bgp ebgp-requires-policy
timers bgp 1 3
neighbor 10.213.21.1 remote-as 65301
neighbor 10.213.21.1 advertisement-interval 0
address-family ipv4 unicast
network 10.213.50.0/24frr defaults traditional
hostname s2
interface eth1
ip address 10.213.22.10/24
! Las dos réplicas anuncian el mismo prefijo, desde el mismo AS.
router bgp 65310
bgp router-id 10.213.22.10
no bgp ebgp-requires-policy
timers bgp 1 3
neighbor 10.213.22.1 remote-as 65302
neighbor 10.213.22.1 advertisement-interval 0
address-family ipv4 unicast
network 10.213.50.0/24Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
"""Pruebas contra Internet: se saltan si no hay salida. Solo revisan protocolos, nunca tiempos."""
import subprocess
import unittest
import internet
SITE = internet.DEFAULT_SITE
PATH = internet.DEFAULT_PATH
def online():
done = subprocess.run(["curl", "-s", "-o", "/dev/null", "--max-time", "6", f"https://{SITE}{PATH}"],
capture_output=True)
return done.returncode == 0
@unittest.skipUnless(online(), f"sin salida a {SITE}")
class RealSite(unittest.TestCase):
def test_http2_and_http3_answer(self):
self.assertEqual(internet.sample("--http2", SITE, PATH)[0], "2")
self.assertEqual(internet.sample("--http3-only", SITE, PATH)[0], "3")
def test_the_site_speaks_tls13(self):
done = subprocess.run(["openssl", "s_client", "-connect", f"{SITE}:443", "-servername", SITE, "-tls1_3"],
input="", capture_output=True, text=True, timeout=20)
self.assertIn("TLSv1.3", done.stdout)
self.assertIn("Verification: OK", done.stdout)
if __name__ == "__main__":
unittest.main()"""Pruebas del laboratorio: se saltan hasta que completen lab/abr.py y lab/mkchain.sh."""
import os
import socket
import subprocess
import tempfile
import time
import unittest
import dash_sim
from netlab import answer_path
def free_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
class Abr(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.choose = staticmethod(dash_sim.load_choose(answer_path("abr.py")))
def test_never_freezes_when_the_network_falls(self):
rows, frozen = dash_sim.simulate(self.choose)
self.assertEqual(frozen, 0.0, "se congeló la imagen")
self.assertGreater(dash_sim.score(rows)[0], 1_200_000, "se queda muy abajo")
def test_jumps_less_than_last_sample(self):
mine, _ = dash_sim.simulate(self.choose, network=dash_sim.OSCILLATING)
theirs, _ = dash_sim.simulate(dash_sim.last_sample, network=dash_sim.OSCILLATING)
self.assertLess(dash_sim.score(mine)[1], dash_sim.score(theirs)[1])
def test_returns_a_valid_index_with_no_samples(self):
index = self.choose(dash_sim.BITRATES, [], 0.0)
self.assertIn(index, range(len(dash_sim.BITRATES)))
class Chain(unittest.TestCase):
NAME = "cadena.redes.test"
@classmethod
def setUpClass(cls):
script = answer_path("mkchain.sh")
cls.folder = tempfile.TemporaryDirectory()
cls.dir = cls.folder.name
done = subprocess.run(["sh", script, cls.dir, cls.NAME], capture_output=True, text=True, timeout=30)
if done.returncode != 0:
cls.folder.cleanup()
raise AssertionError("mkchain.sh falló: " + done.stderr)
@classmethod
def tearDownClass(cls):
cls.folder.cleanup()
def path(self, name):
return os.path.join(self.dir, name)
def extension(self, cert, name):
return subprocess.run(["openssl", "x509", "-in", self.path(cert), "-noout", "-ext", name],
capture_output=True, text=True).stdout
def serve_and_fetch(self, *flags):
port = free_port()
server = subprocess.Popen(
["openssl", "s_server", "-accept", str(port), "-www", "-cert", self.path("leaf.crt"),
"-key", self.path("leaf.key"), *flags],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
time.sleep(0.5)
return subprocess.run(["curl", "-sS", "--max-time", "5", "--cacert", self.path("root.crt"),
"--resolve", f"{self.NAME}:{port}:127.0.0.1", "-o", "/dev/null",
f"https://{self.NAME}:{port}/"], capture_output=True, text=True)
finally:
server.kill()
server.wait()
def test_verify_accepts_the_chain(self):
done = subprocess.run(["openssl", "verify", "-CAfile", self.path("root.crt"), "-untrusted",
self.path("intermediate.crt"), self.path("leaf.crt")],
capture_output=True, text=True)
self.assertEqual(done.returncode, 0, done.stderr)
def test_verify_fails_without_the_intermediate(self):
done = subprocess.run(["openssl", "verify", "-CAfile", self.path("root.crt"), self.path("leaf.crt")],
capture_output=True, text=True)
self.assertNotEqual(done.returncode, 0)
def test_constraints(self):
self.assertIn("CA:TRUE", self.extension("root.crt", "basicConstraints"))
middle = self.extension("intermediate.crt", "basicConstraints")
self.assertIn("CA:TRUE", middle)
self.assertIn("pathlen:0", middle)
self.assertIn("CA:FALSE", self.extension("leaf.crt", "basicConstraints"))
self.assertIn(f"DNS:{self.NAME}", self.extension("leaf.crt", "subjectAltName"))
def test_curl_accepts_when_the_server_sends_the_intermediate(self):
done = self.serve_and_fetch("-cert_chain", self.path("intermediate.crt"))
self.assertEqual(done.returncode, 0, done.stderr)
def test_curl_rejects_a_server_that_forgets_the_intermediate(self):
done = self.serve_and_fetch()
self.assertEqual(done.returncode, 60, done.stderr)
if __name__ == "__main__":
unittest.main()"""Lo que se puede comprobar sin red: criptografía de juguete, simuladores, QUIC y DNS."""
import os
import struct
import tempfile
import unittest
import crypto_toys
import ctr_flip
import dash_sim
import geo_dns
import hol_sim
import quic_initial
from netlab import HERE
class Crypto(unittest.TestCase):
def test_dh_agrees_and_small_primes_break(self):
big_a, _, secret_a, secret_b = crypto_toys.dh_shared(2087, 5, 731, 1289)
self.assertEqual(secret_a, secret_b)
# Probando exponentes se recupera un exponente equivalente al secreto.
found = crypto_toys.dh_break(2087, 5, big_a)
self.assertEqual(pow(5, found, 2087), big_a)
def test_rsa_round_trip(self):
n, e, d = crypto_toys.rsa_keys(61, 53, 17)
self.assertEqual((n, d), (3233, 2753))
for message in (0, 1, 65, 3232):
self.assertEqual(crypto_toys.rsa_apply(crypto_toys.rsa_apply(message, e, n), d, n), message)
self.assertEqual(crypto_toys.rsa_apply(crypto_toys.rsa_apply(message, d, n), e, n), message)
def test_ctr_flip_changes_the_text_and_not_the_rest(self):
cipher = ctr_flip.aes_ctr(ctr_flip.MESSAGE)
self.assertNotEqual(cipher, ctr_flip.MESSAGE)
tampered = ctr_flip.flip(cipher, ctr_flip.MESSAGE.index(b"0100"), ord("0"), ord("9"))
self.assertEqual(ctr_flip.aes_ctr(tampered), b"pagar 9100 soles a Ana")
class QuicInitial(unittest.TestCase):
def test_keys_match_rfc9001_appendix_a(self):
key, iv, hp = quic_initial.client_initial_keys(bytes.fromhex("8394c8f03e515708"))
self.assertEqual(key.hex(), "1f369613dd76d5467730efcbe3b1a22d")
self.assertEqual(iv.hex(), "fa044b2f42a3fd3b46fb255c")
self.assertEqual(hp.hex(), "9f50449e04a0e810283a1e9933adedd2")
def test_decrypts_the_packet_of_rfc9001_appendix_a(self):
with open(os.path.join(HERE, "rfc9001_a2.hex"), encoding="ascii") as file:
packet = bytes.fromhex(file.read())
self.assertEqual(len(packet), 1200)
info = quic_initial.unprotect_initial(packet)
self.assertEqual(info["packet_number"], 2)
self.assertEqual(info["frames"][:16].hex(), "060040f1010000ed0303ebf8fa56f129")
hello = quic_initial.reassemble(quic_initial.crypto_frames(info["frames"]))
self.assertEqual(quic_initial.client_hello_names(hello)[0], "example.com")
def test_reads_the_sni_of_a_real_curl(self):
packets = quic_initial.catch("secreto.redes.test")
# El primer vuelo se rellena hasta 1200 bytes: lo exige QUIC, contra la amplificación.
self.assertTrue(all(len(packet) >= 1200 for packet in packets))
pieces = []
for packet in packets:
pieces += quic_initial.crypto_frames(quic_initial.unprotect_initial(packet)["frames"])
sni, alpn = quic_initial.client_hello_names(quic_initial.reassemble(pieces))
self.assertEqual(sni, "secreto.redes.test")
self.assertIn("h3", alpn)
class QuicFromPcap(unittest.TestCase):
def test_reads_the_sni_from_a_capture_and_stops_at_the_server_cid(self):
packets = quic_initial.catch("captura.redes.test")
later = bytearray(packets[0])
later[6] ^= 0xff # otro DCID: como cuando el cliente ya usa el ID del servidor
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "q.pcap")
with open(path, "wb") as file:
# Formato pcap con enlace "raw IP" (101): IPv4 + UDP, sin Ethernet.
file.write(struct.pack("<IHHiIII", 0xa1b2c3d4, 2, 4, 0, 0, 65535, 101))
for packet in [*packets, bytes(later)]:
udp = struct.pack("!HHHH", 50000, 443, 8 + len(packet), 0) + packet
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), 0, 0, 64, 17, 0,
bytes([10, 0, 0, 1]), bytes([10, 0, 0, 2])) + udp
file.write(struct.pack("<IIII", 0, 0, len(ip), len(ip)) + ip)
found = quic_initial.from_pcap(path)
self.assertEqual(found, packets)
class HeadOfLine(unittest.TestCase):
def test_tcp_delays_every_stream(self):
waits = hol_sim.delayed_by_stream(12, 3, "tcp")
self.assertTrue(all(waits[name][0] > 0 for name in "ABC"))
def test_quic_delays_only_the_stream_with_the_hole(self):
waits = hol_sim.delayed_by_stream(12, 3, "quic")
self.assertGreater(waits["A"][0], 0)
self.assertEqual(waits["B"], [0, 0])
self.assertEqual(waits["C"], [0, 0])
def test_one_stream_is_the_same_in_both(self):
self.assertEqual(hol_sim.deliver(12, 3, "tcp", "A"), hol_sim.deliver(12, 3, "quic", "A"))
def test_without_loss_nobody_waits(self):
for mode in ("tcp", "quic"):
self.assertTrue(all(value == [0, 0] for value in hol_sim.delayed_by_stream(12, None, mode).values()))
class Dash(unittest.TestCase):
def test_top_quality_freezes_and_last_sample_barely(self):
_, frozen_top = dash_sim.simulate(dash_sim.fixed_top)
_, frozen_last = dash_sim.simulate(dash_sim.last_sample)
self.assertGreater(frozen_top, 10)
self.assertLess(frozen_last, 1)
def test_last_sample_follows_the_network(self):
rows, _ = dash_sim.simulate(dash_sim.last_sample)
during = [row["bitrate"] for row in rows if 50 < row["start"] < 65]
self.assertTrue(during)
self.assertTrue(all(rate <= 1_000_000 for rate in during))
self.assertEqual(rows[-1]["bitrate"], 5_000_000)
def test_last_sample_jumps_on_an_oscillating_network(self):
rows, _ = dash_sim.simulate(dash_sim.last_sample, network=dash_sim.OSCILLATING)
self.assertGreater(dash_sim.score(rows)[1], 10)
def query(name, qtype=1):
labels = b"".join(bytes([len(part)]) + part.encode() for part in name.split("."))
return struct.pack("!HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0) + labels + b"\x00" + struct.pack("!HH", qtype, 1)
class GeoDns(unittest.TestCase):
def answer(self, source):
return geo_dns.respond(query(geo_dns.NAME), source)
def test_the_source_decides_the_replica(self):
self.assertTrue(self.answer("10.213.11.10").endswith(bytes([10, 213, 21, 10])))
self.assertTrue(self.answer("10.213.12.10").endswith(bytes([10, 213, 22, 10])))
def test_keeps_the_query_id_and_sets_the_authoritative_flag(self):
reply = self.answer("10.213.11.10")
self.assertEqual(reply[:2], b"\x12\x34")
self.assertEqual(struct.unpack("!H", reply[2:4])[0] & 0x8400, 0x8400)
def test_other_names_and_types_get_an_error_code(self):
self.assertEqual(geo_dns.respond(query("otro.redes.test"), "10.213.11.10")[3] & 0x0f, 3)
self.assertEqual(geo_dns.respond(query(geo_dns.NAME, 28), "10.213.11.10")[3] & 0x0f, 4)
def test_garbage_gets_no_answer(self):
self.assertIsNone(geo_dns.respond(b"\x00" * 5, "10.213.11.10"))
self.assertIsNone(geo_dns.respond(b"\x00" * 12 + b"\x3f" + b"a" * 4, "10.213.11.10"))
if __name__ == "__main__":
unittest.main()"""Lo que dicen los apuntes sobre TLS, migración y DASH, medido en la red c - mb - s."""
import time
import unittest
import scenarios
from netlab import make_pki, wait_until
from scenarios import NET, RTT_MS, SERVER
def setUpModule():
make_pki()
NET.destroy()
NET.deploy()
if not wait_until(lambda: NET.sh("c", "ping", "-c1", "-W1", SERVER).returncode == 0, seconds=40):
NET.destroy()
raise RuntimeError("c no alcanza a s")
def tearDownModule():
NET.destroy()
class HandshakeRounds(unittest.TestCase):
"""Se comparan RTT, con márgenes: otras cosas pueden estar corriendo en la máquina."""
@classmethod
def setUpClass(cls):
scenarios.start_server()
cls.tls13 = scenarios.measure("--tlsv1.3")
cls.tls12 = scenarios.measure("--tlsv1.2", "--tls-max", "1.2")
scenarios.stop_server()
def test_tcp_handshake_takes_about_one_rtt(self):
self.assertGreater(self.tls13[0], RTT_MS * 0.9)
self.assertLess(self.tls13[0], RTT_MS * 2)
def test_tls13_adds_one_rtt(self):
extra = self.tls13[1] - self.tls13[0]
self.assertGreater(extra, RTT_MS * 0.9)
self.assertLess(extra, RTT_MS * 1.8)
def test_tls12_adds_about_two(self):
extra = self.tls12[1] - self.tls12[0]
self.assertGreater(extra, RTT_MS * 1.8)
self.assertLess(extra, RTT_MS * 3)
def test_first_byte_is_three_rtt_with_tls13(self):
self.assertGreater(self.tls13[2], RTT_MS * 2.8)
self.assertLess(self.tls13[2], RTT_MS * 5)
class Observer(unittest.TestCase):
@classmethod
def setUpClass(cls):
NET.sh("c", "rm", "-f", "/w13/captures/keys.log")
scenarios.start_server()
cls.path = scenarios.capture("test-tls")
NET.sh("c", "sh", "-c",
f"SSLKEYLOGFILE=/w13/captures/keys.log curl -s --max-time 5 --tlsv1.3 --http1.1 "
f"--resolve {scenarios.RESOLVE} --cacert /w13/build/pki/ca.crt -o /dev/null {scenarios.URL}")
scenarios.stop_capture()
scenarios.stop_server()
cls.keys = "tls.keylog_file:/w13/captures/keys.log"
def count(self, display, *options):
return scenarios.tshark(self.path, *options, "-Y", display, "-T", "fields", "-e", "frame.number").split()
def test_sni_is_readable_without_keys(self):
out = scenarios.tshark(self.path, "-Y", "tls.handshake.type == 1", "-T", "fields",
"-e", "tls.handshake.extensions_server_name")
self.assertIn(scenarios.NAME, out)
def test_certificate_is_hidden_without_keys(self):
self.assertEqual(self.count("tls.handshake.type == 11"), [])
self.assertEqual(self.count("tls.handshake.type == 20"), [])
self.assertEqual(self.count("http.request"), [])
def test_server_hello_is_in_the_clear(self):
self.assertTrue(self.count("tls.handshake.type == 2"))
def test_the_keylog_shows_certificate_finished_and_request(self):
for display in ("tls.handshake.type == 11", "tls.handshake.type == 15", "tls.handshake.type == 20",
"http.request"):
self.assertTrue(self.count(display, "-o", self.keys), display)
class Peek(unittest.TestCase):
def test_sni_peek_sees_the_name(self):
NET.daemon("s", f"exec /w13/bin/sni_peek {scenarios.PORT} 1 >/tmp/peek.log 2>&1")
ok = wait_until(lambda: "escuchando" in NET.out("s", "cat", "/tmp/peek.log"), seconds=10, step=0.2)
self.assertTrue(ok)
NET.sh("c", "curl", "-s", "--max-time", "2", "--resolve", scenarios.RESOLVE, "-k", scenarios.URL)
wait_until(lambda: "SNI" in NET.out("s", "cat", "/tmp/peek.log"), seconds=5, step=0.2)
self.assertIn(f"SNI: {scenarios.NAME}", NET.out("s", "cat", "/tmp/peek.log"))
class Certificates(unittest.TestCase):
def result(self, cert, *args):
scenarios.start_server(cert)
return NET.sh("c", "curl", "-sS", "--max-time", "5", "--resolve", scenarios.RESOLVE, *args,
"-o", "/dev/null", scenarios.URL)
def tearDown(self):
scenarios.stop_server()
def test_good_with_our_ca(self):
self.assertEqual(self.result("good", "--cacert", "/w13/build/pki/ca.crt").returncode, 0)
def test_good_without_our_ca_is_rejected(self):
done = self.result("good")
self.assertEqual(done.returncode, 60)
self.assertIn("local issuer", done.stderr)
def test_self_signed_is_rejected(self):
done = self.result("selfsigned", "--cacert", "/w13/build/pki/ca.crt")
self.assertEqual(done.returncode, 60)
self.assertIn("self-signed", done.stderr)
def test_wrong_name_is_rejected(self):
done = self.result("wrongname", "--cacert", "/w13/build/pki/ca.crt")
self.assertEqual(done.returncode, 60)
self.assertIn("subject name", done.stderr)
def test_expired_is_rejected(self):
done = self.result("expired", "--cacert", "/w13/build/pki/ca.crt")
self.assertEqual(done.returncode, 60)
self.assertIn("expired", done.stderr)
class Migration(unittest.TestCase):
@classmethod
def setUpClass(cls):
NET.daemon("s", "exec python3 -u /w13/cid_echo.py serve 7000 >/tmp/cid.log 2>&1")
wait_until(lambda: NET.sh("s", "pgrep", "-f", "cid_echo").returncode == 0, seconds=10, step=0.2)
time.sleep(0.5)
@classmethod
def tearDownClass(cls):
NET.sh("s", "pkill", "-f", "cid_echo")
def tearDown(self):
NET.sh("c", "python3", "/w13/cid_echo.py", "restore")
def test_tcp_stops_after_the_move(self):
out = NET.out("c", "python3", "/w13/cid_echo.py", "tcp", SERVER, "7000")
self.assertIn("5: mensaje-5", out)
self.assertIn("respondidos: 6 de 12", out)
def test_connection_id_keeps_going_after_the_move(self):
out = NET.out("c", "python3", "/w13/cid_echo.py", "udp", SERVER, "7000")
self.assertIn("respondidos: 12 de 12", out)
self.assertIn("10.213.3.20", NET.out("s", "cat", "/tmp/cid.log"))
class RealDash(unittest.TestCase):
"""La caída dura 12 s, con un buffer de hasta 10 s: lo que importa es la tendencia."""
def rows(self, strategy):
rows = []
output = scenarios.dash_run(strategy)
for line in output.splitlines():
parts = line.split()
if len(parts) == 6 and parts[0].isdigit():
rows.append((int(parts[0]), float(parts[1][:-1]), int(parts[2][:-1]), float(parts[5][:-1])))
self.assertGreater(len(rows), 20, output)
return rows
def test_last_sample_lowers_quality_during_the_cut_and_recovers(self):
rows = self.rows("last")
before = [rate for _, moment, rate, _ in rows if 3 < moment < 9]
during = [rate for _, moment, rate, _ in rows if 14 < moment < 21]
self.assertTrue(before and during, rows)
self.assertGreater(min(before), max(during), rows)
self.assertGreater(rows[-1][2], max(during), rows)
def test_always_the_top_quality_freezes(self):
rows = self.rows("fixed")
self.assertGreater(sum(row[3] for row in rows), 2, rows)
if __name__ == "__main__":
unittest.main()"""TLS sin topología: sni_peek contra bytes malos, y la repetición de 0-RTT."""
import socket
import subprocess
import time
import unittest
import zero_rtt
from netlab import HERE, make_pki
PEEK = f"{HERE}/bin/sni_peek"
def peek_with(payloads):
"""Lanza sni_peek y le manda cada payload por su propia conexión. Devuelve (salida, código)."""
port = zero_rtt.free_port()
server = subprocess.Popen([PEEK, str(port), str(len(payloads))], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
time.sleep(0.4)
for data in payloads:
with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
sock.sendall(data)
sock.shutdown(socket.SHUT_WR)
sock.recv(16)
return server.communicate(timeout=10)[0], server.returncode
finally:
server.kill()
server.wait()
def real_hello():
"""Un ClientHello real de curl, capturado con un socket propio."""
with socket.create_server(("127.0.0.1", 0)) as listener:
port = listener.getsockname()[1]
curl = subprocess.Popen(["curl", "-s", "-k", "--max-time", "2", "--resolve",
f"oculto.redes.test:{port}:127.0.0.1", f"https://oculto.redes.test:{port}/"])
listener.settimeout(3)
conn, _ = listener.accept()
conn.settimeout(1)
data = b""
try:
while True:
piece = conn.recv(4096)
if not piece:
break
data += piece
if len(data) >= 5 and len(data) >= 5 + int.from_bytes(data[3:5], "big"):
break
finally:
conn.close()
curl.kill()
curl.wait()
return data
class SniPeek(unittest.TestCase):
def test_reads_a_real_client_hello(self):
output, code = peek_with([real_hello()])
self.assertEqual(code, 0)
self.assertIn("SNI: oculto.redes.test", output)
self.assertIn("TLS1.3", output)
def test_survives_every_truncation(self):
hello = real_hello()
cuts = [hello[:size] for size in (0, 1, 4, 5, 6, 9, 11, 43, 44, 80, 200, len(hello) - 1)]
output, code = peek_with(cuts)
self.assertEqual(code, 0, output)
self.assertEqual(output.count("no es un ClientHello completo"), len(cuts), output)
def test_lies_in_the_inner_lengths_do_not_crash_it(self):
hello = bytearray(real_hello())
cases = []
for position in range(9, min(len(hello), 300), 7):
bad = bytearray(hello)
bad[position] = 0xff
cases.append(bytes(bad))
output, code = peek_with(cases)
self.assertEqual(code, 0, output)
self.assertEqual(output.count("conexión de"), len(cases))
def test_control_bytes_in_the_name_do_not_reach_the_terminal(self):
hello = bytearray(real_hello())
at = bytes(hello).index(b"oculto")
hello[at] = 0x1b
output, code = peek_with([bytes(hello)])
self.assertEqual(code, 0, output)
self.assertIn("SNI: \\x1bculto.redes.test", output)
self.assertNotIn("\x1b", output)
def test_not_tls(self):
output, code = peek_with([b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"])
self.assertEqual(code, 0)
self.assertIn("no es un ClientHello", output)
class ZeroRttReplay(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.pki = make_pki()
def test_replay_is_processed_twice_without_protection(self):
heard, before, after = zero_rtt.trial(self.pki, ["-no_anti_replay"])
self.assertIn("accepted", heard)
self.assertEqual((before, after), (1, 2))
def test_replay_is_rejected_with_protection(self):
heard, before, after = zero_rtt.trial(self.pki, [])
self.assertIn("accepted", heard)
self.assertEqual((before, after), (1, 1))
if __name__ == "__main__":
unittest.main()"""CDN: selección por DNS y por anycast, sobre cdn/cdn.clab.yml. Corre después de test_net."""
import subprocess
import time
import unittest
import cdn_demo
from cdn_demo import ANYCAST, CDN, NS, NAME
from netlab import wait_until
def setUpModule():
CDN.destroy()
CDN.deploy()
if not wait_until(cdn_demo.converged, seconds=60):
CDN.destroy()
raise RuntimeError("la red no convergió")
cdn_demo.start_services()
def tearDownModule():
CDN.destroy()
class DnsSelection(unittest.TestCase):
def answer(self, node):
return CDN.out(node, "dig", "+short", f"@{NS}", NAME, "A").strip()
def test_same_name_different_answer(self):
self.assertEqual(self.answer("c1"), "10.213.21.10")
self.assertEqual(self.answer("c2"), "10.213.22.10")
def test_the_chosen_replica_is_the_near_one(self):
self.assertLess(cdn_demo.rtt("c1", "10.213.21.10") + 30, cdn_demo.rtt("c1", "10.213.22.10"))
class Anycast(unittest.TestCase):
def test_same_address_different_replica(self):
self.assertIn("s1", cdn_demo.who("c1"))
self.assertIn("s2", cdn_demo.who("c2"))
def test_both_replicas_are_in_the_bgp_table(self):
table = CDN.vtysh("r1", "show bgp ipv4 unicast 10.213.50.0/24")
self.assertIn("2 available", table)
self.assertIn("65302 65310", table)
def test_failover_serves_from_the_far_replica_and_comes_back(self):
near = cdn_demo.rtt("c1", ANYCAST)
cdn_demo.withdraw("s1")
try:
self.assertTrue(wait_until(lambda: "s2" in cdn_demo.who("c1", wait="1"), seconds=30, step=0.3))
self.assertGreater(cdn_demo.rtt("c1", ANYCAST), near + 30)
finally:
cdn_demo.announce("s1")
self.assertTrue(wait_until(lambda: "s1" in cdn_demo.who("c1", wait="1"), seconds=30, step=0.3))
def test_an_open_connection_does_not_move(self):
download = subprocess.Popen(
["docker", "exec", CDN.prefix + "c1", "curl", "-sS", "-o", "/dev/null", "--limit-rate", "1M",
"--max-time", "30", f"http://{ANYCAST}/grande.bin"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(2.0)
cdn_demo.withdraw("s1")
try:
output = download.communicate(timeout=60)[0]
self.assertEqual(download.returncode, 56, output)
self.assertIn("reset", output)
finally:
cdn_demo.announce("s1")
wait_until(lambda: "s1" in cdn_demo.who("c1", wait="1"), seconds=30, step=0.3)
if __name__ == "__main__":
unittest.main()Laboratorio
Archivos de partida del laboratorio de la semana.
"""Laboratorio: la decisión de un cliente DASH.
choose recibe:
bitrates lista de calidades en bit/s, de menor a mayor
samples throughput medido en cada segmento anterior, en bit/s (vacía al inicio)
buffer_s segundos de video ya bajados y sin reproducir
y devuelve el índice de la calidad para el próximo segmento.
Debe cumplir lo que revisa test_lab.py, con el simulador dash_sim.py:
- en la red NETWORK, nunca congela la imagen y promedia más de 1,2 Mbit/s;
- en la red OSCILLATING, cambia de calidad menos veces que last_sample.
"""
def choose(bitrates, samples, buffer_s):
# TODO: estimen el throughput con varias muestras, dejen margen, y usen el buffer.
raise NotImplementedError#!/bin/sh
# Laboratorio: una cadena de tres niveles, como las de Internet.
#
# mkchain.sh <carpeta> <nombre>
#
# debe dejar en la carpeta:
# root.crt CA raíz, autofirmada, con basicConstraints CA:TRUE
# intermediate.crt CA intermedia firmada por la raíz, con CA:TRUE y pathlen:0
# leaf.crt, leaf.key certificado de servidor firmado por la intermedia, con CA:FALSE
# y subjectAltName DNS:<nombre>
#
# Miren pki.sh para ver cómo se firma con openssl. Lo que revisa test_lab.py:
# - openssl verify -CAfile root.crt -untrusted intermediate.crt leaf.crt aprueba
# - curl acepta al servidor si este manda la intermedia (s_server -cert_chain)
# - curl lo rechaza si el servidor manda solo leaf.crt
set -eu
out=$1
name=$2
mkdir -p "$out"
cd "$out"
# TODO: creen las claves y firmen los tres certificados.
echo "falta completar mkchain.sh para $name" >&2
exit 1Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 13: TLS, QUIC y CDNs. Se usa dentro del contenedor del curso.
#
# make compila sni_peek y crea los certificados de la clase
# make test corre las pruebas (test_*.py): levanta las dos topologías, una tras otra, y
# siempre destruye lo que levantó. Las pruebas del laboratorio usan lab/ y se
# saltan hasta que lo completen; las que necesitan Internet se saltan sin red
# make up levanta la red c - mb - s (net/net.clab.yml)
# make up-cdn levanta la CDN mínima (cdn/cdn.clab.yml)
# make down destruye las dos topologías y sus redes de Docker
# make clean lo anterior, y borra binarios, certificados, segmentos y capturas
CC = gcc
CFLAGS = -Wall -Wextra -std=c17 -D_DEFAULT_SOURCE -O2 -g
LDFLAGS = -static
.PHONY: all test up up-cdn down clean
all: bin/sni_peek build/pki/selfsigned.crt
@mkdir -p captures
bin/%: %.c
@mkdir -p bin
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
build/pki/selfsigned.crt: pki.sh
sh pki.sh build/pki
test: all
@status=0; PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -p 'test_*.py' -v || status=$$?; \
$(MAKE) --no-print-directory down; exit $$status
up: all
cd net && containerlab deploy -t net.clab.yml --reconfigure
up-cdn:
cd cdn && containerlab deploy -t cdn.clab.yml --reconfigure
down:
@cd net && containerlab destroy -t net.clab.yml --cleanup >/dev/null 2>&1 || true
@cd cdn && containerlab destroy -t cdn.clab.yml --cleanup >/dev/null 2>&1 || true
@docker network rm redes-w13-net >/dev/null 2>&1 || true
@docker network rm redes-w13-cdn >/dev/null 2>&1 || true
clean: down
rm -rf bin build captures __pycache__ net/clab-w13-net cdn/clab-w13-cdn