Protocole réseau
Ce que les nœuds Zycord se disent entre eux, et les règles qui empêchent un protocole pair-à-pair de devenir un amplificateur. Un guide de la spécification normative, non un substitut à celle-ci.
La spécification, c'est spec/ : les fichiers de paramètres et le corpus de vecteurs. Là où cette page et l'arbre divergent, c'est l'arbre qui a raison — une implémentation est conforme lorsqu'elle reproduit l'id de genèse et passe les vecteurs, et rien ici n'y change quoi que ce soit. Ce qui suit est une carte de l'emplacement des exigences.
Transport#
TCP, puis TLS 1.3. L'identité d'un pair est une clé Ed25519 portée par un certificat X.509 auto-signé, présenté par les deux parties. Les chaînes de certificats ne sont pas validées — il n'y a pas d'autorité de certification et rien qu'une telle autorité pourrait attester — la vérification exige donc exactement un certificat, exige que sa clé publique soit Ed25519, et extrait cette clé comme identité du pair.
La propriété obtenue est un ancrage du canal à une clé, non une autorisation : TLS garantit que celui qui a mené la poignée de main à son terme détient la clé privée de l'identité présentée, et que le flux ne peut être lu ni modifié en transit. Il ne dit rien de l'honnêteté de ce pair. Rien dans le protocole n'accorde d'autorité sur la base de l'identité.
Les dates de validité des certificats sont des constantes fixes plutôt qu'une fenêtre autour de l'heure courante, et ce à dessein : une fenêtre relative fait dépendre l'acceptation d'un certificat de l'accord des horloges, et un réseau qui se partitionne selon la dérive des horloges est un réseau qui se partitionne pour une raison étrangère au consensus.
Les implémentations ne doivent pas réutiliser comme identité de pair une clé provenant d'une autre couche. Le nœud en génère une nouvelle à chaque démarrage et ne l'écrit jamais sur disque.
Découpage en trames#
offset size field
0 4 length uint32, little-endian - payload length, excluding this header
4 1 kind uint8 - see below
5 n payload
lengthdoit être vérifié contreMaxMessageBytes= 8 Mio, et contre le plafond propre à son type, avant toute allocation. Un destinataire qui alloue d'après une longueur annoncée porte un bug d'épuisement mémoire à distance, quoi qu'il fasse ensuite.- Le
kind0 est refusé. Un type supérieur au plus élevé que connaît une compilation est un type qu'une compilation plus récente a ajouté : sa charge utile est lue et écartée dans la limite deMaxUnknownPayloadBytes= 64 Kio, à un débit borné par pair, plutôt que de fermer la connexion. Au-delà de ce débit, il est noté comme une requête excessive.
Types de messages#
| Valeur | Nom | Direction | Charge utile |
|---|---|---|---|
| 1 | hello | les deux, en premier | Poignée de main |
| 2 | certificate | diffusion | ssz(Certificate) |
| 3 | block-announce | diffusion | En-tête plus les hachages ordonnés des exemplaires de certificats |
| 4 | get-block | requête | identifiant de bloc de 32 octets ‖ index de fragment u32 |
| 5 | block | réponse | Un fragment de ssz(Block) |
| 6 | get-headers | requête | Localisateur |
| 7 | headers | réponse | Suite d'en-têtes |
| 8 | get-peers | requête | vide |
| 9 | peers | réponse | Liste d'adresses |
| 10 | service-card | diffusion, selon la capacité | Une annonce de service signée |
| 11 | block-unavailable | réponse | Identifiant de bloc ‖ raison : sous le plancher de corps de l'émetteur, ou jamais détenu |
| 12 | open-intent | diffusion, selon la capacité | Un intent ouvert pour le chemin forcé |
Il n'existe aucun encodage réseau spécifique d'un objet de consensus, et c'est ce qui fait de « l'identifiant de ce que j'ai reçu » un énoncé vérifiable.
La version du protocole est 3, et la poignée de main exige une correspondance exacte. hello la porte
avec l'identifiant de réseau — l'identifiant de genèse, qui s'engage sur chaque paramètre de consensus —
la hauteur et le travail de l'émetteur, son adresse d'écoute, un mot de capacités, et son plancher
de corps, la plus basse hauteur dont il sert le corps de bloc. Un type soumis à une capacité n'est jamais
envoyé à un pair qui ne l'a pas annoncée, et un bit de capacité qu'une compilation ne définit pas est ignoré,
jamais refusé. Un get-block que le destinataire ne servira pas reçoit pour réponse
block-unavailable plutôt que d'être laissé à l'échéance du demandeur.
Un open-intent est le seul trafic du chemin forcé : l'intent d'un utilisateur sans séquenceur
nommé, que n'importe quel producteur peut exécuter à la fin de son bloc. Le certificat forcé qui en résulte
n'est jamais diffusé en tant que certificat ; il naît dans le bloc, et chaque nœud l'y vérifie.
Chaque message entrant coûte à son expéditeur#
C'est la partie de la spécification qu'il vaut le plus la peine de lire intégralement, car c'est là qu'un protocole pair fuit habituellement. Deux règles la gouvernent.
La règle d'ordonnancement par coût#
Le travail est fait par coût croissant, et un message est facturé avant l'étape coûteuse qui le suit. Un récepteur qui vérifie d'abord et compte ensuite a construit un amplificateur : la chose la moins chère à envoyer est la plus chère à vérifier.
Chaque résultat est tarifé#
Il n'existe aucun Free anonyme. Chaque type de message, croisé avec chaque
résultat qu'il peut produire, figure dans une table assortie d'un score. Un résultat qui traverse la
table sans être facturé est le défaut que cette règle existe pour empêcher — et il a été
atteint en pratique par des voies plus étroites qu'une ligne non tarifée : une implémentation qui détournait une
trame malformée de son unique compteur rendait cette trame gratuite tant qu'une requête restait
en attente, sans ajouter de ligne à aucune table.
Servir est également compté. Les octets de blocs sont la seule réponse dont la taille dépasse de plusieurs ordres de grandeur celle de la requête qui la demande, ils portent donc leur propre budget d'octets en plus du décompte des requêtes.
Les scores s'attachent à l'identité d'un pair : un message invalide coûte 20, une violation de protocole 50, un corps non servi 10 et une requête excessive 5 ; un message utile rapporte 1, et un pair est banni à −100. Les vérifications coûteuses — époques de clé et évaluations de preuve de travail — sont budgétées par pair et remboursées quand le bloc se révèle utile, si bien qu'une annonce qui ne mène nulle part ne peut pas faire moudre du travail à un nœud.
Relais et synchronisation#
Le relais se fait d'abord par hachage. Les certificats sont diffusés seuls et sont vérifiés pour leur validité, sans état et en parallèle, à leur arrivée ; un relais n'a besoin d'aucun état pour filtrer le spam. Les blocs voyagent sous forme d'un en-tête plus les hachages ordonnés des exemplaires, confrontés au mempool du destinataire, et le destinataire vérifie le travail de l'en-tête avant de récupérer le moindre corps. Un corps voyage en fragments de 4 Mio, chacun faisant l'objet de sa propre requête et de sa propre réponse, si bien que la limite de trame borne un message et jamais un bloc. Les corps sont des données de chaîne : un bloc n'est valide que si ses corps peuvent être récupérés, et un nœud qui élague indique où se trouve son plancher lors de la poignée de main.
La synchronisation choisit selon le travail vérifié. Les en-têtes sont validés en tant que chaîne avant que les corps ne soient récupérés, et un nœud préfère le pair dont les en-têtes ont prouvé le plus de travail à celui qui annonce la plus grande hauteur. Une branche concurrente n'est adoptée que si elle se raccorde dans l'horizon d'annulation de 1 024 blocs et porte plus de travail. Les points de contrôle fixés dans la version, acceptés seulement pour des blocs vieux d'au moins une époque, refusent une chaîne contradictoire, quel que soit son poids ; un opérateur peut ajouter un point fixe par option. À partir de la bascule vers la preuve d'enjeu dans l'ère 2, le choix se fait plutôt selon la longueur : la chaîne qui occupe le plus de slots l'emporte (livre blanc §11), et l'horizon d'annulation et les points fixés demeurent, comme défense contre la réécriture avec des clés désengagées depuis.
Gestion des connexions, et la défense contre l'éclipse#
Cette section porte plus d'échecs mesurés que toute autre, et chacune des exigences ci-dessous existe parce qu'une implémentation qui en était dépourvue a été éclipsée lors d'une mesure.
- Les cibles sortantes doivent être choisies avec une diversité d'adresses, afin qu'une seule plage d'hébergement ne puisse pas remplir les emplacements sortants d'un nœud.
- Les cibles sortantes doivent aussi être bornées par source de diffusion. La diversité d'adresses seule ne suffit pas, pour une raison arithmétique plutôt que d'appréciation : un groupe d'adresses est une propriété d'une adresse que l'attaquant possède, mais une adresse qu'un pair déclare n'est que des octets qu'il a inventés, et n'importe quels quatre octets forment un hôte IPv4 valide. Un attaquant sans aucune adresse fabrique un nouveau groupe de diversité par chaîne de caractères pour le prix d'une trame. Ce qu'il ne peut pas inventer, c'est la connexion par laquelle la déclaration est arrivée.
- Le socket par lequel une connexion entrante est arrivée ne doit pas devenir une cible sortante. C'est l'adresse source du pair — un port éphémère choisi par son système d'exploitation, et non un port sur lequel il écoute — un emplacement dépensé à l'appeler est donc un emplacement dépensé sur une adresse qui ne peut pas répondre.
- Les deux bornes doivent être comptées contre les connexions qu'un nœud détient, et non contre un seul appel de sélection. Sinon, une boucle d'appel qui exclut les pairs déjà connectés reconstruit les deux budgets au complet à chaque tour, et la borne ne fait que retarder l'attaquant d'un tour par allocation au lieu de le borner. Mesuré : un seul déclarant a pris 2, puis 4, puis 6, puis 8 emplacements sortants sur 8 en quatre tours.
- Le magasin de pairs doit être persisté, et doit être borné. Un nœud qui redémarre vierge après chaque redémarrage offre à un attaquant une occasion neuve à chaque redémarrage ; un nœud qui redémarre avec l'ardoise de l'attaquant lui offre la même occasion à perpétuité.
- Un magasin borné ne doit pas refuser une adresse bien formée au motif qu'il est plein ; il évince pour elle. Une adresse honnête jamais contactée ne vaut jamais mieux qu'une adresse inventée jamais contactée ; un attaquant qui remplit le magasin en premier le verrouille donc contre tout ce qui est proposé ensuite — y compris la propre liste d'amorçage de l'opérateur.
- Ce que l'éviction choisit importe davantage que le fait qu'elle ait lieu. « Prendre la victime dans la plus grande population » se lit comme une inondation se déplace elle-même, et ne l'est que tant que l'inondation est la plus grande chose dans le magasin. Sur un nœud amorcé depuis un unique pair serviable, la plus grande population est le carnet d'adresses de ce pair. Mesuré sur une implémentation ordonnée ainsi : 200 adresses inventées provenant d'une seule source en ont évincé 200 honnêtes sans aucun coût pour l'attaquant.
- Parmi des entrées indiscernables, le départage final ne doit pas être quelque chose que le pair diffuseur choisit — l'adresse comprise — et cela vaut pour la sélection exactement comme pour l'éviction. Mesuré sur une implémentation dont le sélecteur retombait sur la chaîne d'adresse : 8 adresses honnêtes contre 8 inventées ont renvoyé 8 inventées sur 8, et zéro connexion sortante honnête sur dix tours.
Ce qui est délibérément absent#
| Absent | Pourquoi |
|---|---|
| Traversée de NAT | Le coût est énoncé et la condition de réouverture est mesurée plutôt que supposée : la proportion de nœuds joignables sur le testnet public est la première condition de réouverture de la décision. |
| Compression | Un compresseur sur un flux contrôlé par un attaquant est une surface d'attaque, pour une économie de bande passante dont personne n'a mesuré le besoin. |
| Signatures au niveau des messages | Le transport authentifie le canal ; les objets de consensus portent leurs propres signatures. Une troisième couche authentifierait le relais, ce dont aucune décision ne dépend. |
| Identifiants de requête | Requête et réponse sont appariées par connexion et par ordre ; la synchronisation tourne donc sur sa propre connexion — ce qui supprime une machine à états. Là où un pair n'est pas joignable, la synchronisation peut passer par une connexion de diffusion existante et doit alors apparier les réponses par leur contenu. |
| Négociation de version | La poignée de main exige une version de protocole exacte et déconnecte en cas de divergence. Ce qui évolue sans changement de version est étroit et borné : un nouveau type de message derrière un bit de capacité, qu'une compilation plus ancienne lit et écarte dans la limite de 64 Kio à un débit limité. |
Ce que l'identifiant d'un certificat ne couvre pas#
Il vaut la peine de le redire ici, car cela décide d'une règle de relais. L'identifiant d'un certificat s'engage sur ce qu'il autorise et jamais sur ce qu'il se contente de démontrer, les signatures se tiennent donc hors de la préimage de l'identifiant. Cela fait de la preuve quelque chose que n'importe qui en transit peut remplacer : prenez un certificat, substituez n'importe quoi à sa signature, et propagez — même identifiant, exemplaire désormais invalide.
Deux règles referment cette porte. Un bloc s'engage sur la preuve qu'il porte, par une racine sur les hachages d'exemplaires plutôt que sur les identifiants. Et le relais traite des exemplaires, non des identifiants : un nœud qui reçoit un exemplaire dont la preuve échoue à la vérification écarte cet exemplaire sans préjudice pour l'identifiant — l'identifiant n'est pas marqué, pas mis en cache comme invalide, et un exemplaire ultérieur qui se vérifie est relayé normalement. Une copie mutilée coûte à celui qui l'a mutilée la bande passante de son envoi et ne coûte rien au certificat.
Implémenter ceci depuis zéro#
Une implémentation indépendante qui passe
les vecteurs de référence est un pair, pas un fork — c'est
tout l'intérêt de spécifier ainsi. Commencez par
spec/README.md pour les objets de consensus, puis vérifiez
votre travail avec zcd vectors.