wandres.dev
P2P I · WebRTC y su peso

La señalización: el paso que WebRTC deja fuera a propósito

WebRTC define cómo se conectan dos pares pero no cómo se encuentran, y ese hueco deliberado te obliga a operar un servidor con estado antes de que exista ninguna conexión directa.

⏱ 21 min

La lección anterior terminó en un requisito incómodo: para que dos extremos se crucen los paquetes hace falta que cada uno sepa hacia dónde escribir, y esa información solo puede llegarle por un camino que ya funcione. WebRTC reconoce esa necesidad, define con enorme detalle qué información hay que intercambiar y en qué orden, y a continuación se detiene y no dice cómo transportarla. La omisión no es un olvido ni un asunto pendiente de una versión futura: es una decisión de diseño tomada con los ojos abiertos, y su consecuencia práctica es que cualquiera que quiera conexiones directas entre navegadores tiene que poner en pie, mantener y pagar un servicio con estado que esté disponible en el momento exacto en que dos personas quieren hablar. Esta lección estudia qué viaja por ese canal, por qué la especificación lo dejó abierto, qué formas hay de construirlo y, sobre todo, una propiedad que suele pasar desapercibida y que reorganiza el análisis de seguridad entero.

🎯 Al terminar esta lección sabrás
  • Entender por qué la especificación deja el encuentro fuera y qué gana con ello.
  • Enumerar la información concreta que viaja por el canal de señalización y para qué sirve cada pieza.
  • Reconocer que la huella del certificado viaja por ahí y qué implica eso para el modelo de confianza.
  • Comparar las formas habituales de montar el canal y el coste operativo de cada una.
  • Prever los momentos en que la señalización vuelve a hacer falta después de conectar.

Un hueco deliberado y bien argumentado

El modelo de establecimiento de sesión describe un intercambio de dos mensajes: un extremo produce una oferta con todo lo que sabe ofrecer y el otro devuelve una respuesta con lo que acepta de aquello. El contenido de esos mensajes está normalizado hasta el último campo, en un formato de texto plano heredado de la telefonía sobre red que describe pistas, códecs, atributos criptográficos y direcciones candidatas. Lo que no está normalizado es cómo llega la oferta desde una máquina hasta la otra, y ahí la especificación es explícita: eso es asunto de la aplicación.

El argumento a favor es fuerte y conviene reconocerlo antes de quejarse. WebRTC se diseñó para incorporarse a sistemas de comunicación que ya existían y que ya tenían su propia manera de localizar personas: centralitas empresariales, redes de mensajería, servicios de atención al cliente, plataformas de reuniones. Imponer un protocolo de encuentro habría obligado a todos esos sistemas a duplicar el directorio que ya tenían, y habría fracasado. Al dejarlo abierto, la tecnología encaja igual de bien sobre un canal permanente, sobre una cola de mensajes existente o sobre el propio sistema de presencia de una aplicación de chat.

El argumento tiene un punto ciego, y es justo el que nos afecta. Para un producto que ya opera una infraestructura de comunicación, el hueco es gratis porque ya está tapado. Para una aplicación local-first, cuya premisa entera consiste en no depender de un servidor, el hueco es exactamente el requisito que se quería eliminar, y aparece antes de que se haya transmitido un solo byte de datos útiles.

📝
El intercambio mínimo cabe en dos mensajes copiados a mano

Hay un ejercicio que aclara la naturaleza del problema mejor que cualquier explicación. Genera la oferta en un navegador, imprímela, cópiala a mano y pégala en el otro, haz lo propio con la respuesta y verás la conexión establecerse sin ningún servidor de señalización. Funciona, y demuestra que la información necesaria es finita y pequeña. Lo que demuestra también, y es lo que importa, es que el problema nunca fue el volumen ni el formato: es que alguien tiene que estar en medio en el instante en que las dos partes quieren conectar, y ese alguien, cuando no eres tú con el portapapeles, es un servicio en marcha.

Qué viaja de verdad por ese canal

El contenido del intercambio no es una dirección sino un paquete de decisiones, y merece la pena desglosarlo porque cada pieza tiene consecuencias distintas.

// Lo esencial que atraviesa la senalizacion, agrupado por funcion
const oferta = {
  tipo: "offer",
  // 1. Que se va a transportar y con que parametros
  medios: ["application/webrtc-datachannel"],
  // 2. Credenciales del procedimiento de conectividad
  iceUfrag: "F7gI", icePwd: "x9Kd2LmQpRs7vWzB4nY6cT",
  // 3. Huella del certificado que se usara para cifrar
  fingerprint: "sha-256 4A:2C:...:9F",
  // 4. Direcciones candidatas, que pueden ir goteando despues
  candidatos: []
};

El primer bloque negocia qué se va a transmitir. Para un canal de datos es casi trivial, pero el formato arrastra toda la maquinaria de negociación de códecs que existe por el caso de vídeo, y por eso una descripción de sesión para intercambiar mensajes de texto ocupa varios cientos de líneas que nadie lee.

El segundo bloque son las credenciales del procedimiento de comprobación de conectividad. Las pruebas que los dos extremos se hacen entre sí van autenticadas con ese usuario y esa contraseña efímeros, de modo que un tercero que observe la red no pueda inyectar respuestas falsas ni provocar que dos máquinas se bombardeen entre ellas. Cambiar esas credenciales es, literalmente, lo que significa reiniciar el procedimiento cuando la red cambia.

El tercer bloque es el que casi nadie mira y el que sostiene toda la seguridad: la huella criptográfica del certificado que ese extremo usará para cifrar. Los certificados de WebRTC son autofirmados y no los avala ninguna autoridad, así que la única manera de saber que estás cifrando contra quien crees es comprobar que la huella del certificado presentado coincide con la que te anunciaron por señalización.

El cuarto bloque son las direcciones candidatas, que pueden ir en la oferta o llegar a cuentagotas conforme se descubren. Enviarlas por goteo acorta mucho el tiempo de establecimiento, porque permite empezar a probar parejas mientras todavía se están descubriendo otras, pero exige que el canal de señalización siga abierto durante todo el procedimiento y no solo para dos mensajes.

El servidor que entra por la puerta de atrás

Enumeradas las necesidades, el perfil del servicio que hace falta se dibuja solo, y no es un servicio trivial. Tiene que aceptar conexiones bidireccionales y mantenerlas abiertas, porque las candidatas llegan después de la oferta y porque el otro extremo puede tardar en responder. Tiene que llevar estado de presencia, porque alguien debe saber quién está disponible y en qué sala. Tiene que autenticar, porque de lo contrario cualquiera puede pedir conectar con cualquiera. Y tiene que estar disponible en el instante exacto en que dos personas coinciden, lo que significa disponibilidad continua sin posibilidad de servir desde una caché.

flowchart TD
A[Par A abre canal con el servidor] --> S[Servidor de senalizacion con estado]
B[Par B abre canal con el servidor] --> S
S --> O[oferta de A hacia B]
S --> R[respuesta de B hacia A]
S --> C[goteo de candidatas en ambos sentidos]
C --> D[conexion directa entre A y B]
D --> E[los datos ya no pasan por el servidor]
D --> F[pero el servidor sigue haciendo falta si la red cambia]
style D fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b

Conviene además desmontar la idea de que la señalización es un trámite inicial que luego se olvida. Hace falta otra vez cuando el dispositivo cambia de red y hay que reiniciar el procedimiento con credenciales nuevas, cosa que en movilidad ocurre a diario. Hace falta cuando entra un tercer participante, porque en una malla cada pareja necesita su propio intercambio. Y hace falta cuando una conexión se cae y hay que rehacerla desde cero. En una aplicación con varios dispositivos por persona, el canal de señalización acaba siendo un componente permanente y no un arranque.

📡

Canal permanente propio

Lo más habitual y lo más flexible. Cuesta un proceso con estado, memoria por conexión abierta y una estrategia de reconexión que hay que escribir entera.

📡

Reaprovechar lo que ya tienes

Si tu aplicación ya mantiene un canal con el servidor para otra cosa, la señalización viaja casi gratis por él. Es, con diferencia, la opción más sensata.

📡

Servicio público compartido

Rápido de adoptar y adecuado para prototipos, con el inconveniente de que un tercero ve quién habla con quién y de que su disponibilidad no la controlas tú.

📡

Sin servidor, fuera de banda

Copiar los mensajes a mano o pasarlos por un código visual funciona y elimina la infraestructura, a cambio de una experiencia que muy pocos productos toleran.

La segunda tarjeta merece subrayarse porque resuelve el dilema aparente en muchos proyectos reales. Si tu aplicación local-first ya tiene un servidor de sincronización, aunque sea modesto, ese servidor ya mantiene canales abiertos, ya sabe quién es quién y ya está disponible cuando la gente trabaja. Añadirle el reenvío de un puñado de mensajes de señalización es una cantidad de trabajo casi despreciable y no cambia su perfil operativo. La pregunta interesante, que dejamos para la última lección del nivel, es qué justifica entonces no reenviar también los datos por ahí.

La confianza no viaja por donde viajan los datos

Aquí está la propiedad que reordena todo el análisis y que suele quedar tapada por el entusiasmo del camino directo. La conexión de datos es punto a punto y el servidor de señalización no ve ni un byte de lo que se transmite. Pero la huella del certificado de cada extremo llegó al otro a través de ese servidor, y el procedimiento de cifrado no tiene ninguna otra forma de comprobar contra quién está cifrando.

// Lo que el otro extremo comprueba durante el cifrado
const huellaAnunciada = ofertaRecibida.fingerprint;      // llego por senalizacion
const huellaPresentada = await calcularHuella(certRemoto); // vino por el canal directo
if (huellaAnunciada !== huellaPresentada) abortar();       // unica verificacion posible

// Si quien transporto la senalizacion sustituyo la huella por la suya,
// las dos coinciden perfectamente y nadie detecta nada.

La consecuencia es directa: un servidor de señalización deshonesto puede colocarse en medio de la conversación sustituyendo cada huella por la de un certificado propio, estableciendo dos conexiones cifradas separadas y leyendo todo lo que pasa. El cifrado seguiría siendo impecable en ambos tramos y los dos extremos verían el mismo indicador de conexión establecida. La conexión es directa en el camino de los datos y no lo es en el camino de la confianza, que sigue pasando entera por el servicio que se suponía marginal.

La salida existe y consiste en no depender de la señalización para conocer las claves. Si los dos extremos conocen de antemano la identidad criptográfica del otro, por haberla intercambiado en otro momento y por otra vía, la huella anunciada deja de ser una afirmación que hay que creer y pasa a ser un dato que se puede contrastar. Ese es exactamente el camino que toman las pilas de la generación siguiente, donde la clave pública es la dirección y no hay ningún momento en que un intermediario pueda proponer una distinta. Es también, por cierto, el mismo razonamiento que ya conoces del direccionamiento por contenido: cuando el nombre verifica lo nombrado, el mensajero deja de importar.

💡
Verificación fuera de banda con muy poco esfuerzo

Si tu aplicación tiene ya cualquier canal por el que dos usuarios se han acreditado antes, aprovéchalo para fijar identidades y deja de confiar en la señalización. Guarda la huella la primera vez que dos dispositivos se emparejan y compárala en los encuentros siguientes, exactamente como hace el cliente de acceso remoto de toda la vida. No elimina el problema del primer encuentro, pero lo reduce a una única ventana y convierte cualquier suplantación posterior en un aviso visible. Es media tarde de trabajo y cambia la clase de garantía que puedes prometer.

Descentralizar el camino de los datos no descentraliza el camino de la confianza, y son problemas distintos

Hay un error de razonamiento que aparece una y otra vez en los sistemas distribuidos y que la señalización de WebRTC ilustra con una nitidez difícil de superar: confundir la topología del tráfico con la topología de la autoridad. Un diagrama donde los datos van de A a B sin tocar el centro parece descentralizado, y la sensación es tan convincente que casi nadie sigue preguntando. Pero la pregunta correcta no es por dónde pasan los bytes, sino de qué tiene que fiarse cada extremo para creerse lo que recibe, y esa segunda respuesta puede seguir apuntando a un único punto aunque la primera no lo haga. En WebRTC apunta al servidor de señalización, que no ve ni un byte y sin embargo elige, en la práctica, con quién estás hablando. Esta disociación no es una peculiaridad de esta tecnología: es un patrón general que conviene aprender a detectar. Una red de intercambio de bloques cuyo índice lo publica un servicio es distribuida en el tráfico y central en el descubrimiento. Un sistema de paquetes que se descarga de decenas de espejos es distribuido en el ancho de banda y central en el registro de nombres. Un cliente de mensajería cifrada de extremo a extremo cuyo directorio de claves lo sirve el proveedor tiene los datos fuera de su alcance y la identidad dentro. En los tres casos, y en el nuestro, el punto único no está donde lo dibuja el diagrama de flechas, está donde alguien tiene que creerse algo que no puede verificar por su cuenta. De ahí sale el único criterio robusto para evaluar cualquier arquitectura que se anuncie como descentralizada, y conviene aplicarlo con frialdad al propio diseño antes que al ajeno: haz la lista de cosas que cada participante debe aceptar sin poder comprobarlas, y mira quién se las proporciona. Si esa lista está vacía porque cada afirmación se verifica contra un nombre que ya se tenía, el sistema es genuinamente descentralizado aunque todo el tráfico pase por un solo servidor. Si la lista no está vacía, el sistema tiene un centro, y el centro está exactamente ahí, por muchas flechas directas que dibuje el diagrama.

⚔️ Audita tu canal de encuentro
  1. Escribe qué componente de tu sistema haría de servidor de señalización y qué disponibilidad necesita para que la aplicación funcione.
  2. Comprueba si tu aplicación ya mantiene un canal con estado que pueda transportar el intercambio sin infraestructura nueva.
  3. Enumera todos los momentos posteriores al arranque en que tu diseño necesitaría volver a señalizar.
  4. Describe qué vería exactamente un servidor de señalización malicioso y qué no vería, en tu caso concreto.
  5. Diseña un procedimiento de emparejamiento fuera de banda que fije la identidad de dos dispositivos en el primer encuentro.
  6. Calcula cuántos intercambios de señalización necesita tu aplicación con cinco participantes conectados a la vez.