Protocolo de red
Lo que los nodos de Zycord se dicen entre sí, y las reglas que impiden que un protocolo entre pares se convierta en un amplificador. Una guía de la especificación normativa, no un sustituto de ella.
La especificación es spec/: los archivos de parámetros y el corpus de vectores. Donde esta página y el árbol discrepen, manda el árbol — una implementación es conforme cuando reproduce el id de génesis y pasa los vectores, y nada de lo que hay aquí cambia eso. Lo que sigue es un mapa de dónde están los requisitos.
Transporte#
TCP, luego TLS 1.3. La identidad del par es una clave Ed25519 que va en un certificado X.509 autofirmado, presentado por ambas partes. Las cadenas de certificados no se validan — no hay autoridad de certificación ni nada que una pudiera atestiguar — así que la verificación exige exactamente un certificado, exige que su clave pública sea Ed25519, y extrae esa clave como identidad del par.
La propiedad que se obtiene es una vinculación del canal a una clave, no una autorización: TLS garantiza que quien completó el saludo posee la clave privada de la identidad presentada, y que el flujo no puede leerse ni modificarse en tránsito. No dice nada sobre si ese par es honesto. Nada en el protocolo concede autoridad sobre la base de la identidad.
Las fechas de validez del certificado son constantes fijas y no una ventana alrededor de la hora actual, a propósito: una ventana relativa hace que la aceptación de un certificado dependa de la concordancia de los relojes, y una red que se particiona por desviación de reloj es una red que se particiona por una razón ajena al consenso.
Las implementaciones no deben reutilizar como identidad de par una clave de ninguna otra capa. El nodo genera una nueva en cada arranque y nunca la escribe en disco.
Entramado#
offset size field
0 4 length uint32, little-endian - payload length, excluding this header
4 1 kind uint8 - see below
5 n payload
lengthdebe comprobarse contraMaxMessageBytes= 8 MiB antes de reservar nada. Un receptor que reserva memoria según una longitud declarada tiene un fallo remoto de agotamiento de memoria al margen de lo que venga después.kinddebe estar en[1, 9]. El cero y cualquier cosa por encima del tipo conocido más alto son una violación del protocolo, no una extensión desconocida: en el protocolo 1 no hay escotilla de compatibilidad hacia adelante, porque un campo de versión comprobado en el saludo la hace innecesaria.
Tipos de mensaje#
| Valor | Nombre | Dirección | Carga útil |
|---|---|---|---|
| 1 | hello | ambos, primero | Saludo inicial |
| 2 | certificate | difusión | ssz(Certificate) |
| 3 | block-announce | difusión | Cabecera más ids de certificado |
| 4 | get-block | petición | id de bloque de 32 bytes ‖ índice de fragmento u32 |
| 5 | block | respuesta | Un fragmento de ssz(Block) |
| 6 | get-headers | petición | Localizador |
| 7 | headers | respuesta | Serie de cabeceras |
| 8 | get-peers | petición | vacío |
| 9 | peers | respuesta | Lista de direcciones |
No hay ninguna codificación específica de red de un objeto de consenso, que es lo que hace de "el id de lo que recibí" una afirmación comprobable.
Todo mensaje entrante le cuesta a quien lo envía#
Esta es la parte de la especificación que más merece la pena leer entera, porque es donde un protocolo entre pares suele tener fugas. La rigen dos reglas.
La regla de orden por coste#
El trabajo se hace en orden creciente de coste, y un mensaje se cobra antes del paso caro que le sigue. Un receptor que verifica primero y puntúa después ha construido un amplificador: lo barato de enviar es lo caro de comprobar.
Todo desenlace tiene precio#
No hay ningún Free sin nombre. Cada tipo de mensaje, cruzado con cada
desenlace que puede producir, aparece en una tabla con una puntuación asociada. Un desenlace que atraviesa la
tabla sin ser cobrado es el defecto que esta regla existe para evitar — y se ha
alcanzado en la práctica por vías más estrechas que una fila sin precio: una implementación que desviaba una
trama malformada lejos de su único contador hizo que esa trama fuera gratis mientras hubiera una petición
pendiente, sin añadir ninguna fila a ninguna tabla.
Servir también se mide. Los bytes de un bloque son la única respuesta con órdenes de magnitud más grande que la petición que la solicita, así que llevan su propio presupuesto de bytes además del recuento de peticiones.
Gestión de conexiones, y la defensa contra el eclipse#
Esta sección acumula más fallos medidos que ninguna otra, y cada requisito de abajo existe porque una implementación sin él fue eclipsada en una medición.
- Los objetivos salientes deben elegirse con diversidad de direcciones, de modo que un solo rango de alojamiento no pueda llenar las ranuras salientes de un nodo.
- Los objetivos salientes también deben acotarse por fuente de difusión. La diversidad de direcciones por sí sola no basta, por una razón aritmética y no de criterio: un grupo de direcciones es una propiedad de una dirección que un atacante posee, pero una dirección que un par declara son bytes que se inventó, y cuatro bytes cualesquiera son un host IPv4 válido. Un atacante sin dirección alguna acuña un grupo de diversidad nuevo por cada cadena al precio de una trama. Lo que no puede inventar es la conexión por la que llegó la declaración.
- El socket por el que llegó una conexión entrante no debe convertirse en objetivo saliente. Es la dirección de origen del par — un puerto efímero que eligió su sistema operativo, no algo en lo que escuche — así que una ranura gastada en llamarlo es una ranura gastada en una dirección que no puede responder.
- Ambas cotas deben contarse contra las conexiones que un nodo mantiene, no contra una sola llamada de selección. Si no, un bucle de llamadas que excluye a los pares ya conectados reconstruye ambos presupuestos llenos en cada ronda, y la cota retrasa al atacante una ronda por cada margen en lugar de acotarlo. Medido: un informante tomó 2, luego 4, luego 6, luego 8 de 8 ranuras salientes a lo largo de cuatro rondas.
- El almacén de pares debe persistirse, y debe estar acotado. Un nodo que arranca en blanco tras cada reinicio le da al atacante una oportunidad nueva en cada reinicio; uno que arranca con la lista del atacante se la da de forma permanente.
- Un almacén acotado no debe rechazar una dirección bien formada por estar lleno; desaloja para hacerle sitio. Una dirección honesta nunca contactada no es mejor que una inventada nunca contactada, así que un atacante que llene el almacén primero lo cierra a cal y canto contra todo lo que se ofrezca después — incluida la lista de arranque del propio operador.
- Lo que elige el desalojo importa más que el hecho de que ocurra. "Toma la víctima de la población más grande" se lee como una inundación se desplaza a sí misma, y lo es solo mientras la inundación sea lo más grande del almacén. En un nodo arrancado desde un único par servicial, la población más grande es la libreta de direcciones de ese par. Medido en una implementación ordenada así: 200 direcciones inventadas de una sola fuente desalojaron 200 honestas sin coste alguno para el atacante.
- Entre entradas indistinguibles, el desempate final no debe ser nada que elija el par que difunde — la dirección incluida — y esto se aplica a la selección exactamente igual que al desalojo. Medido en una implementación cuyo selector acababa recurriendo a la cadena de la dirección: 8 direcciones honestas contra 8 inventadas devolvieron 8 de 8 inventadas, y cero conexiones salientes honestas a lo largo de diez rondas.
Lo que está ausente deliberadamente#
| Ausente | Por qué |
|---|---|
| Atravesamiento de NAT | El coste se declara y la condición para reabrir se mide en lugar de suponerse: la proporción de nodos accesibles en la testnet pública es la primera condición para reabrir la decisión. |
| Compresión | Un compresor sobre un flujo controlado por el atacante es una superficie de ataque, a cambio de un ahorro de ancho de banda cuya necesidad nadie ha medido. |
| Firmas a nivel de mensaje | El transporte autentica el canal; los objetos de consenso llevan sus propias firmas. Una tercera capa autenticaría al retransmisor, que no es algo de lo que dependa ninguna decisión. |
| Ids de petición | Petición y respuesta se emparejan por conexión y orden, así que la sincronización corre por su propia conexión — lo que elimina una máquina de estados. Cuando un par no es accesible, la sincronización puede correr sobre una conexión de difusión existente y debe entonces emparejar respuestas por contenido. |
| Un mecanismo de extensión compatible hacia adelante | El protocolo 1 comprueba su versión en el saludo y desconecta si no coincide. Las extensiones llegan como protocolo 2. |
Lo que el id de un certificado no cubre#
Merece la pena repetirlo aquí porque decide una regla de retransmisión. El id de un certificado se compromete con lo que autoriza y nunca con lo que meramente demuestra, así que las firmas quedan fuera de la preimagen del id. Eso hace de la evidencia algo que cualquiera en tránsito puede sustituir: toma un certificado, cambia su firma por basura, y propágalo — mismo id, ejemplar ahora inválido.
Dos reglas lo cierran. Un bloque se compromete con la evidencia que lleva, mediante una raíz sobre hashes de ejemplares en lugar de sobre ids. Y la retransmisión trata con ejemplares, no con ids: un nodo que recibe un ejemplar cuya evidencia no verifica descarta ese ejemplar sin perjuicio del id — el id no se marca, no se cachea como inválido, y un ejemplar posterior que sí verifique se retransmite con normalidad. Una copia mutilada le cuesta a quien la mutiló el ancho de banda de enviarla y no le cuesta nada al certificado.
Implementar esto desde cero#
Una implementación independiente que pase
los vectores de referencia es un par, no un fork — ese
es justo el sentido de especificarlo así. Empieza por
spec/README.md para los objetos de consenso, y luego comprueba
tu trabajo con zcd vectors.