Trencant el límit del QR: El descobriment d'un protocol WebRTC sense servidor
QWBP (QR-WebRTC Bootstrap Protocol) permet connexions peer-to-peer sense servidor comprimint la senyalitzacio WebRTC en codis QR. En dissenyar un protocol binari personalitzat que redueix l'SDP de 2.500 bytes a nomes 55 bytes, dos dispositius poden establir connexions WebRTC encriptades escanejant els codis QR de l'altre—sense necessitat de servidor de senyalitzacio.
L'home raonable s'adapta al mon: el desraonat persisteix a intentar adaptar el mon a si mateix. Per tant, tot progres depen de l'home desraonat.
— George Bernard Shaw
Vaig hardcodejar contrasenyes a produccio. Vaig violar les millors pràctiques de WebRTC. Vaig dissenyar un protocol binari personalitzat. Després ho vaig llençar tot a les escombraries quan vaig descobrir que el veritable problema no era la compressió—era la física.
Aquesta és la història d'una tarda de dijous, un matí de divendres i un protocol desraonat que no hauria d'existir.
La petició d'usuari que no vaig poder respondre
Gener de 2025. Palabreja, el meu joc diari de
paraules en castellà, havia crescut fins a superar els 30.000 jugadors actius
mensuals a Espanya i Llatinoamèrica. Construït com una Progressive Web App (PWA)
estàtica amb zero backend—sense base de dades, sense comptes d'usuari—tot vivia
a localStorage.
Aleshores va arribar la notificació de Bluesky:
"Em compraré un telèfon nou. Com puc mantenir el meu progrés?"
La majoria dels desenvolupadors responen: "Inicia sessió al teu compte." Jo no tenia comptes, ni servidor, ni resposta.
"Actualment, no hi ha manera."
Aquesta resposta em rosegava. Els jugadors que canviaven de telèfon perdrien més de 2 anys de progrés en el joc. Estadístiques mantingudes acuradament durant mesos s'esvairien.
Em negava a muntar una base de dades per moure uns pocs kilobytes de JSON entre dos dispositius asseguts un al costat de l'altre. Volia una transferència directa dispositiu a dispositiu amb zero servidors.
Tarda de dijous: La mentida "Serverless"
Després de la feina, vaig obrir el meu portàtil. WebRTC semblava perfecte — connexions peer-to-peer, APIs natives del navegador, sense servidors de retransmissió.
Tots els tutorials mostraven el mateix patró:
const peer = new RTCPeerConnection();const offer = await peer.createOffer();await peer.setLocalDescription(offer);// Enviar oferta a l'altre parell via... servidor WebSocket?socket.send(JSON.stringify(offer));
Aquí hi era. La senyalització requereix un servidor.
Abans que dos navegadors es connectin peer-to-peer, intercanvien missatges del Session Description Protocol (SDP)—ofertes i respostes que contenen informació de xarxa i paràmetres d'encriptació. L'especificació WebRTC deixa la senyalització sense especificar, assumint que faràs servir WebSockets, HTTP POST o un altre canal mediat per servidor.
Jo no tenia servidor. No volia servidor.
Codis QR. Mostrar l'oferta com un codi QR, escanejar-la amb l'altre telèfon, mostrar la resposta com un altre codi QR, escanejar això. Sense servidor. Comunicació "air-gapped" fent servir pantalles i càmeres.
Vaig construir un prototip. El codi QR va aparèixer.
Era massiu.
Un codi QR Versió 30+—més de 130 mòduls per costat—omplia la pantalla del meu telèfon. Dens, caòtic, illegible.
Resultats de l'escaneig:
- Bona il·luminació, mans fermes: 8 segons, 60% d'èxit
- Habitació fosca: 15+ segons, fallava la majoria d'intents
- Lent ratllada: Mai va tenir èxit
La meva "sincronització instantània" trigava més que teclejar les dades manualment.
Vaig imprimir l'SDP per entendre contra què estava lluitant:
v=0o=- 4682389562847392847 2 IN IP4 127.0.0.1s=-t=0 0a=group:BUNDLE 0a=extmap-allow-mixeda=msid-semantic: WMSm=application 9 UDP/DTLS/SCTP webrtc-datachannelc=IN IP4 0.0.0.0a=ice-ufrag:eP8ja=ice-pwd:3K9m...a=ice-options:tricklea=fingerprint:sha-256 E7:3B:38:46:1A:5D:88:B0:...a=setup:actpassa=mid:0a=sctp-port:5000a=max-message-size:262144a=candidate:1 1 udp 2122260223 192.168.1.100 54321 typ host... (20 línies de candidats més)
2.487 bytes. El Session Description Protocol1 data de 1998, dissenyat per a VoIP on els extrems negocien còdecs de vídeo, taxes de mostreig d'àudio, restriccions d'ample de banda. Jo controlava tots dos extrems. El 90% d'aquestes dades era cerimònia per a una negociació que mai passaria.
La pregunta es va convertir en: "Quins camps de l'SDP són realment necessaris?"
El camí que no vaig agafar
Existeix feina prèvia: seqüències de QR animats que mostren frames fins que l'escàner captura totes les parts23, i fountain codes (TXQR4) que toleren frames perduts. Aquests aconsegueixen ~9 KB/s sota condicions ideals però requereixen mantenir el pols ferm durant 10+ segons—acceptable per signar amb wallets cripto, però massa cerimònia per a un ús casual.
La bifurcació al camí: Els QRs animats resolen el problema de transport—"com moc 2.5KB a través d'un codi QR?". Jo necessitava resoldre el problema de significat—"necessito 2.5KB?".
Vaig mirar llibreries existents com sdp-compact, que eliminen espais en blanc i apliquen compressió estàndard. Però encara així xocaven amb el "Límit de Compressió Genèrica"—la sobrecàrrega de capçaleres i codificació Base64 sovint superava l'estalvi per a càrregues petites.
El hack que va funcionar
Analitzar l'estructura SDP va revelar el que realment es necessitava: credencials ICE, empremta digital (fingerprint) DTLS, valor de setup i candidats ICE. Tota la resta—descripció de sessió, info de bundling, paràmetres SCTP—podia ser hardcodejada en tots dos extrems.
Glossari ràpid per als no iniciats:
- ICE (Interactive Connectivity Establishment): El protocol que esbrina com dos dispositius poden aconseguir-se entre xarxes, tallafocs i NATs.
- Candidats ICE: Adreces de xarxa (IP + port) on un dispositiu pot ser potencialment aconseguit.
- DTLS (Datagram TLS): Capa d'encriptació per a WebRTC—com HTTPS però per a dades en temps real.
- Empremta digital (fingerprint) DTLS: Un hash del certificat de seguretat del dispositiu, usat per verificar que estàs parlant amb el parell correcte.
Primer insight: Hardcodejar les credencials ICE.
a=ice-ufrag:eP8ja=ice-pwd:3K9m...
Aquests són el "fragment d'usuari" (ufrag) i la contrasenya d'ICE—cadenes aleatòries que els parells intercanvien per autenticar les comprovacions de connectivitat. 50 bytes de dades d'alta entropia—impossible de comprimir. Vaig preguntar: "Puc hardcodejar això? Què es trenca?"
Furgar en l'RFC 5245 va revelar la resposta. Les credencials ICE autentiquen les comprovacions de connectivitat entre parells, però la veritable seguretat ve de l'empremta digital DTLS5—un hash SHA-256 del certificat TLS del dispositiu. Un atacant amb credencials ICE però el certificat incorrecte no pot connectar-se; el handshake DTLS falla.
Les vaig hardcodejar:
const ICE_UFRAG = "palabreja";const ICE_PWD = "xK9...........cB0";
Estalviat: 50 bytes.
Segon insight: Filtrar candidats.
Els navegadors emeten 15-30 candidats ICE—cada interfície de xarxa: Wi-Fi, VPN, Docker, IPv6 link-local. La majoria fallen o connecten lent. Però la meva primera prova amb un sol candidat va fallar—la interfície VPN apareixia primer, ocultant l'adreça Wi-Fi que realment podia connectar.
Vaig elevar el límit a 3 candidats "host" (adreces de xarxa local) més 1 candidat "srflx" (reflexiu del servidor). El candidat srflx és la teva adreça IP pública vista des d'internet, descoberta preguntant a un servidor STUN "quina és la meva IP?". Això gestiona el cas on els dispositius estan en xarxes diferents.
Estalviat: 1.200+ bytes.
Tercer insight: Protocol binari.
Vaig mirar fixament el JSON minificat que estava transmetent. Claudàtors.
Cometes. Noms de claus. La cadena "type" apareixia a cada missatge—5 bytes per
codificar alguna cosa que només podia ser "offer" o "answer". El fingerprint era
una cadena hexadecimal de 95 caràcters amb dos punts, però per sota eren només
32 bytes de dades raw (crues).
JSON està dissenyat per a interoperabilitat—llegible per humans, autodescriptiu, universalment parsejable. Però jo controlava tots dos extrems i escrivia el codificador i el descodificador. Res d'això necessitava ser llegible per humans o autodescriptiu.
Vaig recordar estudiar xarxes de baix nivell—com les capçaleres TCP empaqueten flags, números de seqüència i ports en posicions fixes. Sense noms de camp. Sense delimitadors. Només bytes en offsets coneguts. I si dissenyava un format de paquet en lloc d'un objecte JSON?
Eliminar tot el que és constant. Mantenir només el dinàmic:
┌────────┬─────────────────────┬──────────────────────────────┐│ Byte 0 │ Bytes 1-32 │ Bytes 33+ │├────────┼─────────────────────┼──────────────────────────────┤│ Tipus │ Fingerprint DTLS │ Candidats ICE (empaq.) ││ 0=offer│ Hash SHA-256 │ "h|u|192.168.1.5|54321|..." │└────────┴─────────────────────┴──────────────────────────────┘
Un byte per al tipus en lloc de "type":"offer". 32 bytes raw per al
fingerprint en lloc de 95 caràcters ASCII. Sense claudàtors, sense cometes,
sense noms de camp.
Però no havia acabat. Els candidats seguien sent cadenes:
"h|u|192.168.1.5|54321". Aquesta adreça IP sola són 13 caràcters—però una
adreça IPv4 són només 4 bytes. Per què tres caràcters ASCII per a 192 quan
0xC0 n'hi ha prou?
Vaig anar més enllà. Cada candidat es va convertir en una estructura binària de disseny fix:
┌─────────┬────────────────┬────────┐│ Flags │ Adreça IP │ Port ││ (1B) │ (4B o 16B) │ (2B) │└─────────┴────────────────┴────────┘Byte de Flags (màscara de bits):Bits 0-1: Família d'adreça (00=IPv4, 01=IPv6, 10=reservat*)Bit 2: Protocol (0=UDP, 1=TCP)Bit 3: Tipus de candidat (0=host, 1=srflx)Bits 4-5: Tipus TCP[^6] (si TCP): 00=passive, 01=active, 10=soBits 6-7: Reservats*El slot reservat es torna important més tard—les funcions de privadesa del navegador ho requereixen.
La cadena "h|u|192.168.1.5|54321" (21 caràcters) es va convertir en 7 bytes.
Una reducció del 66% només en dades de candidats—i els candidats eren el gruix
de la càrrega útil.
L'estructura completa del paquet:
┌─────────┬─────────────────┬─────────────────────────────────┐│ Camp │ Mida │ Descripció │├─────────┼─────────────────┼─────────────────────────────────┤│ Tipus │ 1 byte │ 0x00 = offer, 0x01 = answer ││ FP │ 32 bytes │ Fingerprint DTLS (SHA-256) ││ Cand 1 │ 7 bytes (IPv4) │ Flags + IP + Port ││ │ 19 bytes (IPv6) │ ││ Cand 2 │ 7-19 bytes │ (repetir fins a fi de payload) │