Comment fonctionne la preuve de travail dans une blockchain

Dans cet article, je vous propose de créer une blockchain en TypeScript à partir de zéro, pour en comprendre le fonctionnement, mais surtout savoir ce qui rend la blockchain aussi robuste et sécurisée, grâce notamment au mécanisme de preuve de travail.

Comment fonctionne la preuve de travail dans une blockchain
Photo by Jose Fontano / Unsplash

Dans cet article, je vous propose de créer une blockchain en TypeScript à partir de zéro, pour en comprendre le fonctionnement, mais surtout savoir ce qui rend la blockchain aussi robuste et sécurisée, grâce notamment au mécanisme de preuve de travail.

Qu'est-ce qu'un bloc ?

Commençons par modéliser ce que doit être un bloc en TypeScript.

interface Block {
  data: string;
  hash: string;
}

Cette interface représente notre bloc et est composée de deux éléments.

Le premier est la propriété data qui correspond aux données que nous souhaitons stocker dans la blockchain. Cela peut être du texte, les informations d'une transaction, le code en base64 d'une image, etc. C'est au créateur de la blockchain de décider ce qu'il peut stocker ou non, sachant que stocker beaucoup de données peut à terme surcharger la blockchain, qui peut devenir immense en termes de taille de données. C'est un point à considérer avant de décider quoi stocker.

La deuxième propriété hash correspond à une valeur déterministe qui permettra de lier les blocs entre eux.

La troisième est le nonce. Nous n'en parlons pas tout de suite, cette propriété sera ajoutée au fur et à mesure que nous avançons et son utilité sera expliquée à ce moment-là.

Maintenant que nous savons de quoi est constitué un bloc, commençons par créer le premier bloc, ou autrement appelé le « genesis block » en anglais.

const genesisBlock: Block = {
  data: "Genesis",
  hash: "",
}

Toute blockchain commence nécessairement par un bloc original afin de faciliter le calcul des blocs suivants. Tout doit partir d'une origine afin de rendre les prochains blocs cohérents entre eux dans la blockchain.

Maintenant que nous avons notre tout premier bloc, voyons comment créer le suivant.

const firstBlock: Block = {
  data: "First",
  hash: "",
};

Désormais, notre blockchain contient deux blocs, créons un troisième bloc maintenant.

const secondBlock: Block = {
  data: "Second",
  hash: "",
};

Ok, nous voilà avec un troisième bloc... Vous avez remarqué ?

Nos blocs ne sont pas liés entre eux. Il est impossible de vérifier si notre bloc firstBlock est bien le second bloc, car le hash n'est pas calculé à partir du précédent. C'est un souci !

Comment hasher un bloc ?

Pour remédier à ce problème, nous allons créer une fonction pour le calculer.

import { createHash } from "node:crypto";
function createBlock(previousBlock: Block, data: string): Block {
  const payload = JSON.stringify({
    data,
    hash: previousBlock.hash,
  });
  const hash = createHash("sha256").update(payload).digest("hex");
  return {
    data,
    hash,
  };
}

Cette fonction accepte deux arguments : le précédent bloc, que nous souhaitons lier au prochain, ainsi que les données qui correspondront au contenu de notre bloc.

Ensuite, nous créons une donnée, ou aussi appelée « payload », qui nous permettra de rassembler toutes les informations qui constituent l'unicité de notre bloc.

Une fois que cela est fait, nous pouvons désormais créer notre hash qui est un appel à la fonction createHash qui crée un hash, ici SHA256 pour éviter au maximum les collisions, et qui nous renvoie le hash associé au format hexadécimal.

Maintenant que nous avons notre hash, nous pouvons créer notre bloc, c'est ce que nous faisons au retour de la fonction.

Donc si on résume, pour créer un bloc, nous avons besoin d'un hash précédent, qui correspond au hash du bloc précédent auquel nous souhaitons nous attacher. Et ainsi nous pouvons créer un hash pour le bloc suivant qui repose sur les données et le hash du bloc précédent.

Modifions notre code pour prendre cela en compte.

const genesisBlock: Block = {
  data: "Genesis",
  hash: "",
};
const firstBlock: Block = createBlock(genesisBlock, "First");
// 0f8fda85e2c7108142acaf86524654d5d86fceaa78184cbb76ab094be609c240
const secondBlock: Block = createBlock(firstBlock, "Second");
// b456d51a908e30fab1795ce0e95e5ee4d8c4434d5ba21ba68501137f85989c74

Dans ce code, nous avons simplement remplacé le corps des deux blocs par un appel à la fonction createBlock.

Notez également que le bloc de génèse n'est pas touché, nous n'avons pas de bloc précédent au bloc de génèse, donc il n'y a pas besoin de le créer en utilisant cette fonction.

Enfin, c'est une information importante : notez que si nous relançons ce code à l'identique, les hash seront toujours les mêmes, quoi qu'il arrive.

Cependant, essayez de modifier les données de chacun des blocs, et vous vous retrouvez avec des hash qui n'ont plus rien à voir.

const genesisBlock: Block = {
  data: "Genesis",
  hash: "",
};
const firstBlock: Block = createBlock(genesisBlock, "Premier");
// b202331cf3abe7f5b4f24e2fad2f647274f79b3e12b334838080a65fb17c16ab
const secondBlock: Block = createBlock(firstBlock, "Second");
// a9e4fb4c9e60d6a6128cc1cf73ca960d4e134cd026e3d7def740e84ba55c0ddb

Vous constatez ici qu'en modifiant le premier bloc, non seulement son hash s'est modifié, mais aussi celui du second, alors que nous ne l'avons pas touché !

Retenez ceci car nous en aurons besoin pour comprendre la suite.

Prouver la qualité de son travail

Sans le savoir, nous avons laissé ici une faille de sécurité majeure qui empêche une telle blockchain de pouvoir passer à un environnement de production et qui est : le temps.

En effet, rien ne nous empêche ici de vouloir modifier le contenu du premier bloc. Et ceci est un problème car si, à la place d'avoir du texte simple, nous avons un récapitulatif de transactions, cela permettrait à n'importe qui avec un ordinateur un minimum puissant de pouvoir modifier l'ensemble de la chaîne, et donc des transactions.

C'est-à-dire par exemple pouvoir se rajouter des milliards de bitcoin, pas terrible pour un système monétaire de confiance...

Afin de pallier cela, il existe plusieurs mécanismes de sécurité, mais le mécanisme le plus connu et qui est finalement trivial à implémenter dans le code est la preuve de travail, ou « proof of work » en anglais.

La preuve de travail consiste à faire en sorte qu'un ordinateur ne puisse tout simplement pas modifier et donc reconstruire chacun des blocs de la blockchain facilement. Et donc pour cela nous devons trouver une manière de forcer l'ordinateur à prendre du temps pour calculer ce hash. Mais comment faire ?

Faisons une petite expérience ensemble. Nous allons générer 5 hash différents, avec des données différentes.

const firstHash = createHash("sha256").update("First").digest("hex");
// a151ceb1711aad529a7704248f03333990022ebbfa07a7f04c004d70c167919f
const secondHash = createHash("sha256").update("Second").digest("hex");
// 8b88a85089561b7978c4e52c3150112912125f3d34ec59b6ff1450fc1079979c
const thirdHash = createHash("sha256").update("Third").digest("hex");
// 5e7425d72f32c5c80a5f7a131585657b80723ae22e01fd69776828feb19e3bd5
const fourthHash = createHash("sha256").update("Fourth").digest("hex");
// c737c0b33eb30959da477494b9d8f26222b30a473236ed884324654f5a29fc84
const fifthHash = createHash("sha256").update("Fifth").digest("hex");
// b612a40113eac372f7794b600086655dcb6ef6e6c451d9e6b140ed6dd5097d47

Il y a deux choses à remarquer avec ce code.

La première, c'est qu'il est possible d'avoir un hash différent en modifiant la donnée qui est celle que reçoit la méthode update.

Et la deuxième, et la plus importante, c'est qu'il n'y a aucun hash qui commence par 0 !

C'est une information importante, car pour pouvoir y arriver, il faudrait trouver une donnée qui soit adaptée pour l'algorithme SHA256 afin de pouvoir générer une telle chaîne de caractères.

Mais nous ne voudrions pas que le contenu soit modifié pour autant, c'est important pour nous de nous assurer que le hash est cohérent par rapport à ce qu'il représente dans un bloc.

Pour cela, nous allons rajouter un petit grain de sel, ou « salt » en anglais. Comme je vais essayer de me rapprocher au plus des termes qui sont utilisés dans une blockchain, nous allons appeler ces données plutôt un « nonce », qui signifie un nombre utilisé une seule fois.

Désormais, nos données ne seront plus juste une chaîne de caractères mais un objet qui sera ensuite sérialisé. Cela ne vous rappelle rien ? C'est exactement ce que nous avons fait dans la fonction createBlock !

const payload = JSON.stringify({
  data: "First",
  nonce: 0,
});
const firstHash = createHash("sha256").update(payload).digest("hex");
// 0bb24293d666071a8cb89bf5b5a660621ffdeab0a2221939346aab0ad3c4e4d3

Ah ! C'est en fait une vraie coïncidence et en préparant l'article, je m'attendais à devoir utiliser d'autres nombres jusqu'à arriver à un hash qui commence par un zéro, mais c'est en réalité une opportunité pour parler de sécurité de nouveau.

Puisqu'ici j'ai rajouté une seconde donnée, notre fameux nonce, j'ai pu obtenir bien entendu un hash différent, tout en ayant mon texte dans la charge utile, ou « payload ».

Cependant, cela ne m'a toujours pas pris suffisamment de temps pour rendre cela robuste et empêcher les attaquants de facilement modifier une blockchain complète, et si nous calculons le temps que cela a pris, mon ordinateur (portable avec CPU Ryzen 7 16 cœurs) a mis 447 ms pour pouvoir finir de hasher cette chaîne de caractères.

Donc il est facile de pouvoir construire un hash qui commence par un zéro, autrement dit, la difficulté n'est pas suffisante.

La difficulté dans une blockchain utilisant la preuve de travail comme consensus de validation des blocs, c'est concrètement un nombre de 0 devant le hash qui va permettre d'augmenter plus ou moins la difficulté de calcul d'un hash pour un bloc.

Étant donné qu'il est plutôt rare d'un point de vue entropique d'avoir par le plus grand des hasard un certain nombre de zéro devant un hash, ce qui sera une belle coïncidence, le consensus a été de se mettre d'accord sur un certain nombre de 0 par lequel doit commencer un hash pour être considéré comme valide.

Si maintenant nous mettons une difficulté supplémentaire, et nous nous disons qu'il nous faut désormais 2 zéros devant le hash pour qu'il soit considéré comme valide, alors nous pourrions modifier le nonce en l'augmentant de 1, et voir ce que cela donne.

const payload = JSON.stringify({
  data: "First",
  nonce: 1,
});
const firstHash = createHash("sha256").update(payload).digest("hex");
// 9fab1d558cbd4e4a66e0398ce011fbf22f3c85cd919db7c5a6d647cc5b0f30c1

C'est devenu beaucoup plus dur, non seulement augmenter le nonce de 1 ne m'a pas permis d'obtenir un hash commençant par deux zéros, mais pire encore : il l'a rendu totalement incompatible puisqu'il ne commence par aucun zéro !

C'est un problème, et il faudrait en réalité trouver le nonce qui permettrait d'avoir un niveau de difficulté suffisant pour pouvoir valider le bloc.

Un travail facile n'est pas un travail intéressant

Pour les plus fainéants d'entre vous qui ne veulent pas incrémenter de 1 et tester, la réponse est 451.

const payload = JSON.stringify({
  data: "First",
  nonce: 451,
});
const firstHash = createHash("sha256").update(payload).digest("hex");
// 00ddaa9f6bf3d39de2ba96ad54af4a4550ada8251dee5f14fe1ca7dc9291885f

Évidemment, n'essayez pas de faire cela à la main 451 fois ! Utilisez à votre avantage votre langage de programmation et construisez une boucle vous permettant de résoudre ce problème.

let hash = "";
let nonce = -1;
do {
  nonce = nonce + 1;
  const payload = JSON.stringify({
    data: "First",
    nonce,
  });
  hash = createHash("sha256").update(payload).digest("hex");
} while (!hash.startsWith("00"));
console.log(nonce);
// 451
console.log(hash);
// 00ddaa9f6bf3d39de2ba96ad54af4a4550ada8251dee5f14fe1ca7dc9291885f

C'est littéralement comme cela que fonctionne la preuve de travail : votre ordinateur est configuré pour itérer sur ce nombre autant de fois que nécessaire pour atteindre la difficulté (ici de 2) dans le temps le plus court possible (au détriment d'une facture d'électricité conséquente).

Le coût énergétique étant suffisamment conséquent pour pouvoir modifier une blockchain complète contenant des milliards de transactions différentes et donc autant de blocs, il est alors indispensable d'avoir : soit les capitaux financiers nécessaires pour pouvoir assumer le coût énergétique de devoir recalculer les hash de tous les blocs suivants la modification, soit les capacités de calcul nécessaires, et souvent les deux en même temps.

Mais si l'attaquant a déjà les capitaux financiers nécessaires pour le faire, à quoi bon dépenser de l'argent pour essayer (sans garantie) d'en gagner en retour ?

C'est cela qui fait la beauté de la preuve de travail : plus la difficulté augmente, plus il est coûteux en infrastructure et en capitaux de devoir modifier la blockchain afin de créer des transactions (et donc potentiellement des Bitcoin) à partir de rien (pas vraiment puisqu'on échangerait du coup de l'argent contre du Bitcoin).

Mais surtout, plus de temps il faudrait pour pouvoir recalculer les hash des blocs suivants dans la blockchain ! Et même avec tout cela, il faudrait être cohérent avec toutes les transactions et les sommes engagées.

Pour pouvoir normaliser la difficulté, nous pourrions créer une simple variable qui contiendra la difficulté souhaitée.

const difficulty = 2;
let hash: string = "";
let nonce = -1;
do {
  nonce = nonce + 1;
  const payload = JSON.stringify({
    data: "First",
    nonce,
  });
  hash = createHash("sha256").update(payload).digest("hex");
} while (!hash.startsWith("0".repeat(difficulty)));

Ici, j'ai simplement utilisé la méthode repeat sur les chaînes de caractères afin de pouvoir facilement modifier la difficulté qui est stockée dans une constante difficulty.

Vous pouvez d'ailleurs lancer ce script avec tsx et tester son temps d'exécution avec la méthode console.time et console.timeEnd.

Sur mon ordinateur, une difficulté de 1 prend 307 ms, pour une difficulté de 2 cela met 1,37 s, pour une difficulté de 3 nous mettons 11 secondes, pour une difficulté de 4 nous mettons 82 s, etc.

Comme vous pouvez le voir, la difficulté augmente exponentiellement, et si vous essayez une difficulté supérieure à 6, il faudra un ordinateur bien plus puissant car cela risque de prendre un certain temps.

Nous pourrions d'ailleurs utiliser un sous-processus dans un green thread avec un système qui reposerait sur un générateur asynchrone, voir utiliser les threads du noyau avec le module node:child_process, c'est un exercice que je laisse au lecteur de cet article.

Pour rendre cette blockchain réellement authentique, il faudrait faire en sorte que la donnée du hash soit aussi dépendante du hash précédent, ainsi lorsqu'un attaquant distant souhaite modifier le bloc n°572 d'une blockchain contenant 1673 blocs, il faudrait aussi recalculer les hash des blocs n°573 à n°1673 pour pouvoir modifier le bloc n°572.

Si la difficulté est suffisamment élevée, cela pourrait amener à nécessiter une puissance de calcul bien trop importante pour pouvoir être faisable, et donc impossible en pratique car il faudrait aussi prendre en compte que la blockchain grossira entre temps, avec d'autres transactions arrivant, il faudrait continuer à garder le rythme afin de pouvoir prétendre à arriver à une blockchain cohérente.

Et quand bien même cela arriverait, il faudrait que tous les autres nœuds du réseau aient les mêmes hash recalculés, couplé à la décentralisation de la blockchain, cela serait finalement en pratique impossible car il faudrait arriver à trouver une faille sur tous les nœuds qui constituent le réseau et modifier également leur propre copie de la blockchain.

Voilà donc ce que donnerait le code avec le calcul du hash précédent.

const previousBlock: Block = {
  data: "Genesis",
  nonce: 0,
  hash: "",
};
const data = "First";
const difficulty = 2;
let hash: string = "";
let nonce = -1;
do {
  nonce = nonce + 1;
  const payload = JSON.stringify({
    data,
    previousHash: secondBlock.hash,
    nonce,
  });
  hash = createHash("sha256").update(payload).digest("hex");
} while (!hash.startsWith("0".repeat(difficulty)));
const nextBlock: Block = {
  data,
  nonce,
  hash
};

Tu ne modifieras point

Si nous devions proposer un code un peu plus propre et organisé par-dessus tout cela, nous pourrions imaginer avoir une classe Blockchain qui ressemblerait à ceci.

import { createHash } from "node:crypto";
interface Block {
  data: string;
  nonce: number;
  hash: string;
}
class Blockchain {
  public constructor(
    public readonly blocks: Block[] = [{ data: "Genesis", nonce: 0, hash: "" }],
    public readonly difficulty: number = 5,
  ) {}
  public createBlock(data: string): void {
    const previousBlock = this.blocks.at(-1);
    if (!previousBlock) {
      throw new Error("Genesis block required to start a blockchain.");
    }
    let hash: string = "";
    let nonce = -1;
    do {
      nonce = nonce + 1;
      const payload = JSON.stringify({
        data,
        hash: previousBlock.hash,
        nonce,
      });
      hash = createHash("sha256").update(payload).digest("hex");
    } while (!hash.startsWith("0".repeat(this.difficulty)));
    this.blocks.push({
      data,
      nonce,
      hash,
    });
  }
  public validate() {
    let previousBlock = this.blocks.at(0);
    if (!previousBlock) {
      return false;
    }
    for (const block of this.blocks.slice(1)) {
      const { data, nonce } = block;
      const payload = JSON.stringify({ data, hash, nonce });
      const hash = createHash("sha256").update(payload);
    }
  }
}

Et nous pourrions tester notre blockchain avec le code suivant.

const myBlockchain = new Blockchain();
myBlockchain.createBlock("First");
// data:"First"
// hash:"0000030d950dd22226b693fd44629a29edbb364f788a750548a4c32e6f6693b4"
myBlockchain.createBlock("Second");
// data:"Second"
// hash:"0000019f3442ed9630b51d7488094dbd1222887710a784f09cfaea23eadb5b9d"

Une question subsiste, étant donné que j'ai utilisé un langage de programmation qui manipule des structures de données mutables, comment être sûr que celle-ci ne soit pas corrompue ou modifiée ?

Rien ne m'empêcherait d'aller sur le code, et de modifier un bloc, puis de le renvoyer sur le réseau. Et rien n'empêche non plus de rejoindre un réseau, télécharger la blockchain complète pour me rendre compte des années après que les blocs que j'avais reçu étaient en fait corrompus. Même si en pratique nous avons compris que cela serait bien trop difficile à mettre en place, c'est une question intéressante à se poser.

Comment faire confiance aux autres, pouvoir travailler avec d'autres nœuds sur le réseau, et m'assurer que ce que je reçois est bien valide ?

Bien que la création de blocs soit algorithmiquement très lente, à l'inverse, vérifier la validité d'une blockchain, même très dense, est en réalité très rapide.

Voici le code en question d'une telle méthode de validation de la blockchain.

interface InvalidBlock extends Block {
  expectedHash: string;
}
public invalidBlock(): InvalidBlock | null {
  let previousBlock = this.blocks.at(0);
  if (!previousBlock) {
    return {
      data: "Genesis",
      nonce: 0,
      hash: "",
      expectedHash: "",
    };
  }
  for (const block of this.blocks.slice(1)) {
    const { hash } = previousBlock;
    const { data, nonce } = block;
    const payload = JSON.stringify({ data, hash, nonce });
    const expectedHash = createHash("sha256").update(payload).digest("hex");
    if (expectedHash !== block.hash) {
      return {
        ...block,
        expectedHash,
      };
    }
    previousBlock = block;
  }
  return null;
}

L'algorithme est le suivant.

Nous bouclons sur tous les blocs, en pensant à récupérer d'abord le premier bloc, le bloc de génèse.

Nous récupérons le hash du bloc précédent, la donnée du bloc à vérifier ainsi que son nonce.

Nous calculons sa charge utile, et nous construisons son hash de cette manière.

Une fois que nous avons le hash, en toute logique, celui-ci devrait correspondre au hash que nous avons dans la blockchain. Si ce n'est pas le cas, nous renvoyons le bloc en défaut.

Si nous testons sur notre blockchain actuelle, cela marchera sans problème et notre blockchain est évidemment valide puisqu'elle n'a pas été touchée.

const myBlockchain = new Blockchain();
myBlockchain.createBlock("First");
myBlockchain.createBlock("Second");
const invalidBlock = myBlockchain.invalidBlock();
if (invalidBlock) {
  console.error("Found an invalid block.");
  console.error(JSON.stringify(invalidBlock));
  // N'apparaît pas dans la console
}

Néanmoins, si nous essayons d'effectuer une mutation sur notre blockchain, la console affichera une erreur directement.

const myBlockchain = new Blockchain();
myBlockchain.createBlock("First");
myBlockchain.createBlock("Second");
const secondBlock = myBlockchain.blocks.at(1);
if (secondBlock) {
  secondBlock.data = "Give Amin 10000 BTC because why not";
}
const invalidBlock = myBlockchain.invalidBlock();
if (invalidBlock) {
  console.error("Found an invalid block.");
  console.error(JSON.stringify(invalidBlock));
}

Et nous aurions ce message qui apparaîtrait dans la console.

Found an invalid block.
{
  "data": "Give Amin 10000 BTC because why not",
  "nonce": 1694950,
  "hash": "00000e76ce687f84b4bfebb257b1ecc94abd2972288943ae949491f9b999df23",
  "expectedHash": "17e237860699bf1709d71dc136338c0ca1181fd9be9839374039c239bf0692f8"
}

Donc, pour pouvoir rendre notre blockchain valide en modifiant cette dernière, il faudrait modifier également le nonce, donc trouver un nouveau nonce, et calculer un nouveau hash (donc dépenser de la puissance de calcul et de l'électricité) pour tous les blocs suivants, qui dépendent des précédents.

Dans notre si petit exemple, cela pourrait tout à fait se faire, puisque notre blockchain est très petite, et que notre difficulté est faible.

Mais dans le contexte d'une blockchain publique comme le Bitcoin, c'est chaque jour de plus en plus difficile, car le nombre de blocs ajoutés au réseau est immense, et surtout la difficulté est aussi élevée, qui n'a cessé d'augmenter graduellement depuis sa création.

Conclusion

Si on résume, une blockchain est une longue liste contiguë et chaînée, qui représente tous les blocs sur le réseau.

Un bloc contient des données, selon l'objectif de cette blockchain, cela peut être des diplômes, documents légaux, transactions financières, fichiers multimédias, etc.

Ces blocs doivent être par la nature du métier sous-jacent immuables, et donc ne pas pouvoir être altérés par un attaquant externe. Il est donc nécessaire de mettre en place un mécanisme de protection contre la falsification de ces données. Ici, nous avons décrit le mécanisme de la preuve de travail, mais il existe de nombreux autres mécanismes, un des plus connus étant la preuve d'enjeu, ou « proof of stake » pour les intimes et qui est utilisé sur la blockchain Ethereum par exemple.

À l'aide de primitives cryptographiques comme le hash SHA256, il est alors possible de créer des données uniques à chaque bloc qui viennent confirmer l'ajout du bloc dans la blockchain. Et puisqu'un bloc est hashé à partir du hash du bloc précédent, si nous devions un jour modifier une transaction, il faudrait également modifier le hash de toutes les transactions précédentes, mais aussi les suivantes, pour avoir une liste cohérente. C'est ce qui rend la blockchain aujourd'hui sûre contre les attaques de modification.

Néanmoins, avec les avancées de la cryptographie moderne, il pourrait tout à fait être possible dans un futur plus ou moins lointain de pouvoir casser cette architecture avec un chiffrement déployé sur un ordinateur quantique. Cependant, les avancées sont incertaines pour l'instant bien que le risque quantique existe aujourd'hui bel et bien.

Bien entendu, nous ne sommes pas allés jusqu'à créer un réseau pair-à-pair, ni même un système permettant de gérer des transactions avec des portefeuilles, ou « wallet ». Ceci est un exercice laissé pour le lecteur afin d'en savoir plus sur les applications concrètes de la blockchain.