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 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.kinddoit être dans[1, 9]. Zéro et tout ce qui dépasse le type connu le plus élevé constituent une violation du protocole, et non une extension inconnue : il n'y a pas d'échappatoire de compatibilité ascendante au protocole 1, parce qu'un champ de version vérifié à la poignée de main en rend une inutile.
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 identifiants 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 |
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.
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.
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. |
| Un mécanisme d'extension à compatibilité ascendante | Le protocole 1 vérifie sa version à la poignée de main et se déconnecte en cas de divergence. Les extensions arriveront avec le protocole 2. |
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.