Passer au contenu principal

Utiliser la divulgation nulle de connaissance pour un état secret

serveur
hors chaîne
centralisé
à divulgation nulle de connaissance
zokrates
mud
confidentialité
Avancé
Ori Pomerantz
15 mars 2025
31 minutes de lecture

Il n'y a pas de secrets sur la chaîne de blocs. Tout ce qui est publié sur la chaîne de blocs est ouvert et peut être lu par tout le monde. Cela est nécessaire, car la chaîne de blocs repose sur le fait que n'importe qui puisse la vérifier. Cependant, les jeux s'appuient souvent sur un état secret. Par exemple, le jeu du démineur (opens in a new tab) n'a absolument aucun sens si vous pouvez simplement aller sur un explorateur de blocs et voir la carte.

La solution la plus simple consiste à utiliser un composant serveur pour conserver l'état secret. Cependant, la raison pour laquelle nous utilisons la chaîne de blocs est d'empêcher le développeur du jeu de tricher. Nous devons garantir l'honnêteté du composant serveur. Le serveur peut fournir un hash de l'état, et utiliser des preuves à divulgation nulle de connaissance pour prouver que l'état utilisé pour calculer le résultat d'un mouvement est le bon.

Après avoir lu cet article, vous saurez comment créer ce type de serveur conservant un état secret, un client pour afficher l'état, et un composant onchain pour la communication entre les deux. Les principaux outils que nous utiliserons seront :

OutilObjectifVérifié sur la version
Zokrates (opens in a new tab)Preuves à divulgation nulle de connaissance et leur vérification1.1.9
TypeScript (opens in a new tab)Langage de programmation pour le serveur et le client5.4.2
Node (opens in a new tab)Exécution du serveur20.18.2
Viem (opens in a new tab)Communication avec la chaîne de blocs2.9.20
MUD (opens in a new tab)Gestion des données onchain2.0.12
React (opens in a new tab)Interface utilisateur client18.2.0
Vite (opens in a new tab)Servir le code client4.2.1

Exemple du Démineur

Le Démineur (opens in a new tab) est un jeu qui inclut une carte secrète avec un champ de mines. Le joueur choisit de creuser à un emplacement spécifique. Si cet emplacement contient une mine, la partie est terminée. Sinon, le joueur obtient le nombre de mines dans les huit cases entourant cet emplacement.

Cette application est écrite en utilisant MUD (opens in a new tab), un framework qui nous permet de stocker des données onchain à l'aide d'une base de données clé-valeur (opens in a new tab) et de synchroniser ces données automatiquement avec des composants hors chaîne. En plus de la synchronisation, MUD facilite la mise en place d'un contrôle d'accès, et permet à d'autres utilisateurs d'étendre (opens in a new tab) notre application sans permission.

Exécuter l'exemple du démineur

Pour exécuter l'exemple du démineur :

  1. Assurez-vous d'avoir installé les prérequis (opens in a new tab) : Node (opens in a new tab), Foundry (opens in a new tab), git (opens in a new tab), pnpm (opens in a new tab) et mprocs (opens in a new tab).

  2. Clonez le dépôt.

    git clone https://github.com/qbzzt/20240901-secret-state.git
    
  3. Installez les paquets.

    cd 20240901-secret-state/
    pnpm install
    npm install -g mprocs
    

    Si Foundry a été installé dans le cadre de pnpm install, vous devez redémarrer le terminal de ligne de commande.

  4. Compilez les contrats

    cd packages/contracts
    forge build
    cd ../..
    
  5. Démarrez le programme (y compris une chaîne de blocs anvil (opens in a new tab)) et patientez.

    mprocs
    

    Notez que le démarrage prend beaucoup de temps. Pour voir la progression, utilisez d'abord la flèche vers le bas pour faire défiler jusqu'à l'onglet contracts afin de voir les contrats MUD en cours de déploiement. Lorsque vous obtenez le message Waiting for file changes…, les contrats sont déployés et la suite de la progression se fera dans l'onglet server. Là, vous patientez jusqu'à obtenir le message Verifier address: 0x.....

    Si cette étape réussit, vous verrez l'écran mprocs, avec les différents processus sur la gauche et la sortie de la console pour le processus actuellement sélectionné sur la droite.

    The mprocs screen

    S'il y a un problème avec mprocs, vous pouvez exécuter les quatre processus manuellement, chacun dans sa propre fenêtre de ligne de commande :

    • Anvil

      cd packages/contracts
      anvil --base-fee 0 --block-time 2
      
    • Contracts

      cd packages/contracts
      pnpm mud dev-contracts --rpc http://127.0.0.1:8545
      
    • Server

      cd packages/server
      pnpm start
      
    • Client

      cd packages/client
      pnpm run dev
      
  6. Vous pouvez maintenant naviguer vers le client (opens in a new tab), cliquer sur New Game, et commencer à jouer.

Tables

Nous avons besoin de plusieurs tables (opens in a new tab) onchain.

  • Configuration : Cette table est un singleton, elle n'a pas de clé et contient un seul enregistrement. Elle est utilisée pour conserver les informations de configuration du jeu :

    • height : La hauteur d'un champ de mines
    • width : La largeur d'un champ de mines
    • numberOfBombs : Le nombre de bombes dans chaque champ de mines
  • VerifierAddress : Cette table est également un singleton. Elle est utilisée pour conserver une partie de la configuration, l'adresse du contrat vérificateur (verifier). Nous aurions pu mettre cette information dans la table Configuration, mais elle est définie par un composant différent, le serveur, il est donc plus simple de la placer dans une table séparée.

  • PlayerGame : La clé est l'adresse du joueur. Les données sont :

    • gameId : Valeur de 32 octets qui est le hash de la carte sur laquelle le joueur joue (l'identifiant du jeu).
    • win : un booléen indiquant si le joueur a gagné la partie.
    • lose : un booléen indiquant si le joueur a perdu la partie.
    • digNumber : le nombre de creusages réussis dans la partie.
  • GamePlayer : Cette table contient le mappage inverse, de gameId vers l'adresse du joueur.

  • Map : La clé est un tuple de trois valeurs :

    • gameId : Valeur de 32 octets qui est le hash de la carte sur laquelle le joueur joue (l'identifiant du jeu).
    • Coordonnée x
    • Coordonnée y

    La valeur est un nombre unique. Elle est de 255 si une bombe a été détectée. Sinon, c'est le nombre de bombes autour de cet emplacement plus un. Nous ne pouvons pas simplement utiliser le nombre de bombes, car par défaut, tout le stockage dans l'EVM et toutes les valeurs de ligne dans MUD sont à zéro. Nous devons faire la distinction entre « le joueur n'a pas encore creusé ici » et « le joueur a creusé ici, et a trouvé qu'il y a zéro bombe autour ».

De plus, la communication entre le client et le serveur s'effectue via le composant onchain. Cela est également implémenté à l'aide de tables.

  • PendingGame : Demandes non traitées pour démarrer une nouvelle partie.
  • PendingDig : Demandes non traitées pour creuser à un endroit spécifique dans une partie spécifique. Il s'agit d'une table hors chaîne (opens in a new tab), ce qui signifie qu'elle n'est pas écrite dans le stockage de l'EVM, elle est uniquement lisible hors chaîne à l'aide d'événements.

Flux d'exécution et de données

Ces flux coordonnent l'exécution entre le client, le composant onchain et le serveur.

Initialisation

Lorsque vous exécutez mprocs, ces étapes se produisent :

  1. mprocs (opens in a new tab) exécute quatre composants :

  2. Le paquet contracts déploie les contrats MUD puis exécute le script PostDeploy.s.sol (opens in a new tab). Ce script définit la configuration. Le code de GitHub spécifie un champ de mines de 10x5 contenant huit mines (opens in a new tab).

  3. Le serveur (opens in a new tab) commence par configurer MUD (opens in a new tab). Entre autres choses, cela active la synchronisation des données, de sorte qu'une copie des tables pertinentes existe dans la mémoire du serveur.

  4. Le serveur abonne une fonction pour qu'elle soit exécutée lorsque la table Configuration change (opens in a new tab). Cette fonction (opens in a new tab) est appelée après que PostDeploy.s.sol s'exécute et modifie la table.

  5. Lorsque la fonction d'initialisation du serveur dispose de la configuration, elle appelle zkFunctions (opens in a new tab) pour initialiser la partie à divulgation nulle de connaissance du serveur. Cela ne peut pas se produire tant que nous n'avons pas la configuration, car les fonctions à divulgation nulle de connaissance doivent avoir la largeur et la hauteur du champ de mines comme constantes.

  6. Une fois la partie à divulgation nulle de connaissance du serveur initialisée, l'étape suivante consiste à déployer le contrat de vérification à divulgation nulle de connaissance sur la chaîne de blocs (opens in a new tab) et à définir l'adresse du vérificateur dans MUD.

  7. Enfin, nous nous abonnons aux mises à jour afin de voir quand un joueur demande soit de démarrer une nouvelle partie (opens in a new tab), soit de creuser dans une partie existante (opens in a new tab).

Nouvelle partie

Voici ce qui se passe lorsque le joueur demande une nouvelle partie.

  1. S'il n'y a pas de partie en cours pour ce joueur, ou s'il y en a une mais avec un gameId de zéro, le client affiche un bouton de nouvelle partie (opens in a new tab). Lorsque l'utilisateur appuie sur ce bouton, React exécute la fonction newGame (opens in a new tab).

  2. newGame (opens in a new tab) est un appel System. Dans MUD, tous les appels sont acheminés via le contrat World, et dans la plupart des cas, vous appelez <namespace>__<function name>. Dans ce cas, l'appel est destiné à app__newGame, que MUD achemine ensuite vers newGame dans GameSystem (opens in a new tab).

  3. La fonction onchain vérifie que le joueur n'a pas de partie en cours, et s'il n'y en a pas, ajoute la demande à la table PendingGame (opens in a new tab).

  4. Le serveur détecte le changement dans PendingGame et exécute la fonction abonnée (opens in a new tab). Cette fonction appelle newGame (opens in a new tab), qui à son tour appelle createGame (opens in a new tab).

  5. La première chose que fait createGame est de créer une carte aléatoire avec le nombre approprié de mines (opens in a new tab). Ensuite, il appelle makeMapBorders (opens in a new tab) pour créer une carte avec des bordures vides, ce qui est nécessaire pour Zokrates. Enfin, createGame appelle calculateMapHash, pour obtenir le hash de la carte, qui est utilisé comme identifiant du jeu.

  6. La fonction newGame ajoute la nouvelle partie à gamesInProgress.

  7. La dernière chose que fait le serveur est d'appeler app__newGameResponse (opens in a new tab), qui est onchain. Cette fonction se trouve dans un System différent, ServerSystem (opens in a new tab), pour permettre le contrôle d'accès. Le contrôle d'accès est défini dans le fichier de configuration de MUD (opens in a new tab), mud.config.ts (opens in a new tab).

    La liste d'accès n'autorise qu'une seule adresse à appeler le System. Cela restreint l'accès aux fonctions du serveur à une seule adresse, de sorte que personne ne puisse usurper l'identité du serveur.

  8. Le composant onchain met à jour les tables pertinentes :

    • Crée la partie dans PlayerGame.
    • Définit le mappage inverse dans GamePlayer.
    • Supprime la demande de PendingGame.
  9. Le serveur identifie le changement dans PendingGame, mais ne fait rien car wantsGame (opens in a new tab) est faux.

  10. Sur le client, gameRecord (opens in a new tab) est défini sur l'entrée PlayerGame pour l'adresse du joueur. Lorsque PlayerGame change, gameRecord change également.

  11. S'il y a une valeur dans gameRecord, et que la partie n'a pas été gagnée ou perdue, le client affiche la carte (opens in a new tab).

Creuser

  1. Le joueur clique sur le bouton de la cellule de la carte (opens in a new tab), ce qui appelle la fonction dig (opens in a new tab). Cette fonction appelle dig onchain (opens in a new tab).

  2. Le composant onchain effectue un certain nombre de vérifications de cohérence (opens in a new tab), et en cas de succès, ajoute la demande de creusage à PendingDig (opens in a new tab).

  3. Le serveur détecte le changement dans PendingDig (opens in a new tab). S'il est valide (opens in a new tab), il appelle le code à divulgation nulle de connaissance (opens in a new tab) (expliqué ci-dessous) pour générer à la fois le résultat et une preuve qu'il est valide.

  4. Le serveur (opens in a new tab) appelle digResponse (opens in a new tab) onchain.

  5. digResponse fait deux choses. Tout d'abord, il vérifie la preuve à divulgation nulle de connaissance (opens in a new tab). Ensuite, si la preuve est validée, il appelle processDigResult (opens in a new tab) pour traiter réellement le résultat.

  6. processDigResult vérifie si la partie a été perdue (opens in a new tab) ou gagnée (opens in a new tab), et met à jour Map, la carte onchain (opens in a new tab).

  7. Le client récupère les mises à jour automatiquement et met à jour la carte affichée au joueur (opens in a new tab), et le cas échéant, indique au joueur s'il s'agit d'une victoire ou d'une défaite.

Utiliser Zokrates

Dans les flux expliqués ci-dessus, nous avons ignoré les parties à divulgation nulle de connaissance, en les traitant comme une boîte noire. Maintenant, ouvrons-la et voyons comment ce code est écrit.

Hachage de la carte

Nous pouvons utiliser ce code JavaScript (opens in a new tab) pour implémenter Poseidon (opens in a new tab), la fonction de hachage Zokrates que nous utilisons. Cependant, bien que cela soit plus rapide, ce serait également plus compliqué que d'utiliser simplement la fonction de hachage Zokrates pour le faire. Ceci est un tutoriel, et le code est donc optimisé pour la simplicité, non pour les performances. Par conséquent, nous avons besoin de deux programmes Zokrates différents, un pour simplement calculer le hash d'une carte (hash) et un pour créer réellement une preuve à divulgation nulle de connaissance du résultat de la fouille à un emplacement sur la carte (dig).

La fonction de hachage

C'est la fonction qui calcule le hash d'une carte. Nous allons passer en revue ce code ligne par ligne.

import "hashes/poseidon/poseidon.zok" as poseidon;
import "utils/pack/bool/pack128.zok" as pack128;

Ces deux lignes importent deux fonctions de la bibliothèque standard de Zokrates (opens in a new tab). La première fonction (opens in a new tab) est un hash Poseidon (opens in a new tab). Elle prend un tableau d'éléments field (opens in a new tab) et renvoie un field.

L'élément de champ dans Zokrates fait généralement moins de 256 bits de long, mais de peu. Pour simplifier le code, nous limitons la carte à un maximum de 512 bits, et hachons un tableau de quatre champs, et dans chaque champ nous n'utilisons que 128 bits. La fonction pack128 (opens in a new tab) transforme un tableau de 128 bits en un field à cet effet.

def hashMap(bool[${width+2}][${height+2}] map) -> field {

Cette ligne commence la définition d'une fonction. hashMap reçoit un seul paramètre appelé map, un tableau bidimensionnel de bool(éens). La taille de la carte est de width+2 par height+2 pour des raisons qui sont expliquées ci-dessous.

Nous pouvons utiliser ${width+2} et ${height+2} car les programmes Zokrates sont stockés dans cette application sous forme de chaînes de modèles (template strings) (opens in a new tab). Le code entre ${ et } est évalué par JavaScript, et de cette façon le programme peut être utilisé pour différentes tailles de carte. Le paramètre de la carte a une bordure d'un emplacement de large tout autour sans aucune bombe, c'est la raison pour laquelle nous devons ajouter deux à la largeur et à la hauteur.

La valeur de retour est un field qui contient le hash.

bool[512] mut map1d = [false; 512];

La carte est bidimensionnelle. Cependant, la fonction pack128 ne fonctionne pas avec des tableaux bidimensionnels. Nous aplatissons donc d'abord la carte en un tableau de 512 octets, en utilisant map1d. Par défaut, les variables Zokrates sont des constantes, mais nous devons attribuer des valeurs à ce tableau dans une boucle, nous le définissons donc comme mut (opens in a new tab).

Nous devons initialiser le tableau car Zokrates n'a pas de undefined. L'expression [false; 512] signifie un tableau de 512 valeurs false (opens in a new tab).

u32 mut counter = 0;

Nous avons également besoin d'un compteur pour distinguer les bits que nous avons déjà remplis dans map1d de ceux que nous n'avons pas remplis.

for u32 x in 0..${width+2} {

C'est ainsi que vous déclarez une boucle for (opens in a new tab) dans Zokrates. Une boucle for Zokrates doit avoir des limites fixes, car bien qu'elle semble être une boucle, le compilateur la « déroule » en réalité. L'expression ${width+2} est une constante au moment de la compilation car width est défini par le code TypeScript avant d'appeler le compilateur.

for u32 y in 0..${height+2} {
         map1d[counter] = map[x][y];
         counter = counter+1;
      }
   }

Pour chaque emplacement sur la carte, placez cette valeur dans le tableau map1d et incrémentez le compteur.

field[4] hashMe = [
        pack128(map1d[0..128]),
        pack128(map1d[128..256]),
        pack128(map1d[256..384]),
        pack128(map1d[384..512])
    ];

Le pack128 pour créer un tableau de quatre valeurs field à partir de map1d. Dans Zokrates, array[a..b] signifie la tranche du tableau qui commence à a et se termine à b-1.

return poseidon(hashMe);
}

Utilisez poseidon pour convertir ce tableau en un hash.

Le programme de hachage

Le serveur doit appeler hashMap directement pour créer des identifiants de jeu. Cependant, Zokrates ne peut appeler que la fonction main sur un programme pour démarrer, nous créons donc un programme avec un main qui appelle la fonction de hachage.

${hashFragment}

def main(bool[${width+2}][${height+2}] map) -> field {
    return hashMap(map);
}

Le programme de fouille

C'est le cœur de la partie à divulgation nulle de connaissance de l'application, où nous produisons les preuves qui sont utilisées pour vérifier les résultats des fouilles.

${hashFragment}

// Le nombre de mines à l'emplacement (x,y)
def map2mineCount(bool[${width+2}][${height+2}] map, u32 x, u32 y) -> u8 {
   return if map[x+1][y+1] { 1 } else { 0 };
}

Pourquoi une bordure de carte

Les preuves à divulgation nulle de connaissance utilisent des circuits arithmétiques (opens in a new tab), qui n'ont pas d'équivalent simple à une instruction if. Au lieu de cela, elles utilisent l'équivalent de l'opérateur conditionnel (opens in a new tab). Si a peut être soit zéro soit un, vous pouvez calculer if a { b } else { c } comme ab+(1-a)c.

Pour cette raison, une instruction if Zokrates évalue toujours les deux branches. Par exemple, si vous avez ce code :

bool[5] arr = [false; 5];
u32 index=10;
return if index>4 { 0 } else { arr[index] }

Il générera une erreur, car il doit calculer arr[10], même si cette valeur sera ensuite multipliée par zéro.

C'est la raison pour laquelle nous avons besoin d'une bordure d'un emplacement de large tout autour de la carte. Nous devons calculer le nombre total de mines autour d'un emplacement, ce qui signifie que nous devons voir l'emplacement une ligne au-dessus et en dessous, à gauche et à droite, de l'emplacement où nous fouillons. Ce qui signifie que ces emplacements doivent exister dans le tableau de la carte fourni à Zokrates.

def main(private bool[${width+2}][${height+2}] map, u32 x, u32 y) -> (field, u8) {

Par défaut, les preuves Zokrates incluent leurs entrées. Il ne sert à rien de savoir qu'il y a cinq mines autour d'un endroit à moins de savoir réellement de quel endroit il s'agit (et vous ne pouvez pas simplement le faire correspondre à votre requête, car le prouveur pourrait alors utiliser des valeurs différentes sans vous le dire). Cependant, nous devons garder la carte secrète, tout en la fournissant à Zokrates. La solution consiste à utiliser un paramètre private, qui n'est pas révélé par la preuve.

Cela ouvre une autre voie aux abus. Le prouveur pourrait utiliser les bonnes coordonnées, mais créer une carte avec n'importe quel nombre de mines autour de l'emplacement, et potentiellement à l'emplacement lui-même. Pour éviter cet abus, nous faisons en sorte que la preuve à divulgation nulle de connaissance inclue le hash de la carte, qui est l'identifiant du jeu.

return (hashMap(map),

La valeur de retour ici est un tuple qui inclut le tableau de hash de la carte ainsi que le résultat de la fouille.

if map2mineCount(map, x, y) > 0 { 0xFF } else {

Nous utilisons 255 comme valeur spéciale au cas où l'emplacement lui-même contiendrait une bombe.

map2mineCount(map, x-1, y-1) + map2mineCount(map, x, y-1) + map2mineCount(map, x+1, y-1) +
            map2mineCount(map, x-1, y) + map2mineCount(map, x+1, y) +
            map2mineCount(map, x-1, y+1) + map2mineCount(map, x, y+1) + map2mineCount(map, x+1, y+1)
         }
   );
}

Si le joueur n'a pas touché de mine, ajoutez le nombre de mines pour la zone autour de l'emplacement et renvoyez-le.

Utiliser Zokrates depuis TypeScript

Zokrates possède une interface en ligne de commande, mais dans ce programme, nous l'utilisons dans le code TypeScript (opens in a new tab).

La bibliothèque qui contient les définitions Zokrates s'appelle zero-knowledge.ts (opens in a new tab).

import { initialize as zokratesInitialize } from "zokrates-js"

Importez les liaisons JavaScript de Zokrates (opens in a new tab). Nous n'avons besoin que de la fonction initialize (opens in a new tab) car elle renvoie une promesse qui se résout avec toutes les définitions Zokrates.

export const zkFunctions = async (width: number, height: number) : Promise<any> => {

De la même manière que Zokrates lui-même, nous n'exportons également qu'une seule fonction, qui est aussi asynchrone (opens in a new tab). Lorsqu'elle finit par renvoyer un résultat, elle fournit plusieurs fonctions comme nous le verrons ci-dessous.

const zokrates = await zokratesInitialize()

Initialisez Zokrates, obtenez tout ce dont nous avons besoin de la bibliothèque.

Ensuite, nous avons la fonction de hachage et les deux programmes Zokrates que nous avons vus ci-dessus.

const digCompiled = zokrates.compile(digProgram)
const hashCompiled = zokrates.compile(hashProgram)

Ici, nous compilons ces programmes.

// Créer les clés pour la vérification à divulgation nulle de connaissance.
// Sur un système en production, vous voudrez utiliser une cérémonie de configuration.
// (https://zokrates.github.io/toolbox/trusted_setup.html#initializing-a-phase-2-ceremony).
const keySetupResults = zokrates.setup(digCompiled.program, "")
const verifierKey = keySetupResults.vk
const proverKey = keySetupResults.pk

Sur un système de production, nous pourrions utiliser une cérémonie de configuration (opens in a new tab) plus compliquée, mais cela suffit pour une démonstration. Ce n'est pas un problème que les utilisateurs puissent connaître la clé du prouveur - ils ne peuvent toujours pas l'utiliser pour prouver des choses à moins qu'elles ne soient vraies. Parce que nous spécifions l'entropie (le deuxième paramètre, ""), les résultats seront toujours les mêmes.

Remarque : La compilation des programmes Zokrates et la création de clés sont des processus lents. Il n'est pas nécessaire de les répéter à chaque fois, juste lorsque la taille de la carte change. Sur un système de production, vous les feriez une fois, puis stockeriez la sortie. La seule raison pour laquelle je ne le fais pas ici est par souci de simplicité.

calculateMapHash

const calculateMapHash = function (hashMe: boolean[][]): string {
  return (
    "0x" +
    BigInt(zokrates.computeWitness(hashCompiled, [hashMe]).output.slice(1, -1))
      .toString(16)
      .padStart(64, "0")
  )
}

La fonction computeWitness (opens in a new tab) exécute réellement le programme Zokrates. Elle renvoie une structure avec deux champs : output, qui est la sortie du programme sous forme de chaîne JSON, et witness, qui contient les informations nécessaires pour créer une preuve à divulgation nulle de connaissance du résultat. Ici, nous n'avons besoin que de la sortie.

La sortie est une chaîne de la forme "31337", un nombre décimal entre guillemets. Mais la sortie dont nous avons besoin pour viem est un nombre hexadécimal de la forme 0x60A7. Nous utilisons donc .slice(1,-1) pour supprimer les guillemets, puis BigInt pour convertir la chaîne restante, qui est un nombre décimal, en un BigInt (opens in a new tab). .toString(16) convertit ce BigInt en une chaîne hexadécimale, et "0x"+ ajoute le marqueur pour les nombres hexadécimaux.

// Creuser et retourner une preuve à divulgation nulle de connaissance du résultat
// (code côté serveur)

La preuve à divulgation nulle de connaissance inclut les entrées publiques (x et y) et les résultats (hash de la carte et nombre de bombes).

    const zkDig = function(map: boolean[][], x: number, y: number) : any {
        if (x<0 || x>=width || y<0 || y>=height)
            throw new Error("Trying to dig outside the map")

C'est un problème de vérifier si un indice est hors limites dans Zokrates, nous le faisons donc ici.

const runResults = zokrates.computeWitness(digCompiled, [map, `${x}`, `${y}`])

Exécutez le programme de fouille.

        const proof = zokrates.generateProof(
            digCompiled.program,
            runResults.witness,
            proverKey)

        return proof
    }

Utilisez generateProof (opens in a new tab) et renvoyez la preuve.

const solidityVerifier = `
        // Map size: ${width} x ${height}
        \n${zokrates.exportSolidityVerifier(verifierKey)}
        `

Un vérificateur Solidity, un contrat intelligent que nous pouvons déployer sur la chaîne de blocs et utiliser pour vérifier les preuves générées par digCompiled.program.

    return {
        zkDig,
        calculateMapHash,
        solidityVerifier,
    }
}

Enfin, renvoyez tout ce dont les autres codes pourraient avoir besoin.

Tests de sécurité

Les tests de sécurité sont importants car un bug de fonctionnalité finira toujours par se révéler. Mais si l'application n'est pas sécurisée, cela risque de rester caché pendant longtemps avant d'être révélé par quelqu'un qui triche et s'enfuit avec des ressources qui appartiennent à d'autres.

Permissions

Il y a une entité privilégiée dans ce jeu, le serveur. C'est le seul utilisateur autorisé à appeler les fonctions dans ServerSystem (opens in a new tab). Nous pouvons utiliser cast (opens in a new tab) pour vérifier que les appels aux fonctions à permission ne sont autorisés que pour le compte du serveur.

La clé privée du serveur se trouve dans setupNetwork.ts (opens in a new tab).

  1. Sur l'ordinateur qui exécute anvil (la chaîne de blocs), définissez ces variables d'environnement.

    WORLD_ADDRESS=0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b
    UNAUTHORIZED_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
    AUTHORIZED_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
    
  2. Utilisez cast pour tenter de définir l'adresse du vérificateur en tant qu'adresse non autorisée.

    cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $UNAUTHORIZED_KEY
    

    Non seulement cast signale un échec, mais vous pouvez ouvrir les MUD Dev Tools dans le jeu sur le navigateur, cliquer sur Tables, et sélectionner app__VerifierAddress. Vous verrez que l'adresse n'est pas zéro.

  3. Définissez l'adresse du vérificateur comme étant l'adresse du serveur.

    cast send $WORLD_ADDRESS 'app__setVerifier(address)' `cast address-zero` --private-key $AUTHORIZED_KEY
    

    L'adresse dans app__VerifiedAddress devrait maintenant être zéro.

Toutes les fonctions MUD dans le même System passent par le même contrôle d'accès, je considère donc ce test comme suffisant. Si ce n'est pas votre cas, vous pouvez vérifier les autres fonctions dans ServerSystem (opens in a new tab).

Abus de la divulgation nulle de connaissance

Les mathématiques pour vérifier Zokrates dépassent le cadre de ce tutoriel (et de mes capacités). Cependant, nous pouvons exécuter diverses vérifications sur le code à divulgation nulle de connaissance pour nous assurer que s'il n'est pas exécuté correctement, il échoue. Tous ces tests vont nous obliger à modifier zero-knowledge.ts (opens in a new tab) et à redémarrer l'application entière. Il ne suffit pas de redémarrer le processus du serveur, car cela met l'application dans un état impossible (le joueur a une partie en cours, mais le jeu n'est plus disponible pour le serveur).

Mauvaise réponse

La possibilité la plus simple est de fournir la mauvaise réponse dans la preuve à divulgation nulle de connaissance. Pour ce faire, nous allons dans zkDig et modifions la ligne 91 (opens in a new tab) :

proof.inputs[3] = "0x" + "1".padStart(64, "0")

Cela signifie que nous prétendrons toujours qu'il y a une bombe, quelle que soit la bonne réponse. Essayez de jouer avec cette version, et vous verrez dans l'onglet server de l'écran pnpm dev cette erreur :

cause: {
        code: 3,
        message: 'execution reverted: revert: Zero knowledge verification fail',
        data: '0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000
000000000000000000000000000000000000000000000000205a65726f206b6e6f776c6564676520766572696669636174696f6
e206661696c'
      },

Donc, ce genre de triche échoue.

Mauvaise preuve

Que se passe-t-il si nous fournissons les bonnes informations, mais que nous avons simplement les mauvaises données de preuve ? Maintenant, remplacez la ligne 91 par :

proof.proof = {
  a: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  b: [
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
    ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
  ],
  c: ["0x" + "1".padStart(64, "0"), "0x" + "2".padStart(64, "0")],
}

Cela échoue toujours, mais maintenant cela échoue sans raison car cela se produit pendant l'appel du vérificateur.

Comment un utilisateur peut-il vérifier le code de confiance zéro ?

Les contrats intelligents sont relativement faciles à vérifier. En général, le développeur publie le code source sur un explorateur de blocs, et l'explorateur de blocs vérifie que le code source se compile bien en le code présent dans la transaction de déploiement du contrat. Dans le cas des Systems MUD, c'est légèrement plus compliqué (opens in a new tab), mais pas de beaucoup.

C'est plus difficile avec la divulgation nulle de connaissance. Le vérificateur inclut certaines constantes et effectue des calculs sur celles-ci. Cela ne vous dit pas ce qui est prouvé.

    function verifyingKey() pure internal returns (VerifyingKey memory vk) {
        vk.alpha = Pairing.G1Point(uint256(0x0f43f4fe7b5c2326fed4ac6ed2f4003ab9ab4ea6f667c2bdd77afb068617ee16), uint256(0x25a77832283f9726935219b5f4678842cda465631e72dbb24708a97ba5d0ce6f));
        vk.beta = Pairing.G2Point([uint256(0x2cebd0fbd21aca01910581537b21ae4fed46bc0e524c055059aa164ba0a6b62b), uint256(0x18fd4a7bc386cf03a95af7163d5359165acc4e7961cb46519e6d9ee4a1e2b7e9)], [uint256(0x11449dee0199ef6d8eebfe43b548e875c69e7ce37705ee9a00c81fe52f11a009), uint256(0x066d0c83b32800d3f335bb9e8ed5e2924cf00e77e6ec28178592eac9898e1a00)]);

La solution, du moins jusqu'à ce que les explorateurs de blocs ajoutent la vérification Zokrates à leurs interfaces utilisateur, est que les développeurs d'applications mettent à disposition les programmes Zokrates, et qu'au moins certains utilisateurs les compilent eux-mêmes avec la clé de vérification appropriée.

Pour ce faire :

  1. Installez Zokrates (opens in a new tab).

  2. Créez un fichier, dig.zok, avec le programme Zokrates. Le code ci-dessous suppose que vous avez conservé la taille de carte d'origine, 10x5.

  3. Compilez le code Zokrates et créez la clé de vérification. La clé de vérification doit être créée avec la même entropie que celle utilisée dans le serveur d'origine, dans ce cas une chaîne vide (opens in a new tab).

    zokrates compile --input dig.zok
    zokrates setup -e ""
    
  4. Créez le vérificateur Solidity vous-même, et vérifiez qu'il est fonctionnellement identique à celui sur la chaîne de blocs (le serveur ajoute un commentaire, mais ce n'est pas important).

    zokrates export-verifier
    diff verifier.sol ~/20240901-secret-state/packages/contracts/src/verifier.sol
    

Décisions de conception

Dans toute application suffisamment complexe, il existe des objectifs de conception concurrents qui nécessitent des compromis. Examinons certains de ces compromis et pourquoi la solution actuelle est préférable aux autres options.

Pourquoi la divulgation nulle de connaissance

Pour le démineur, vous n'avez pas vraiment besoin de la divulgation nulle de connaissance. Le serveur peut toujours conserver la carte, puis la révéler entièrement une fois la partie terminée. Ensuite, à la fin de la partie, le contrat intelligent peut calculer le hash de la carte, vérifier qu'il correspond, et si ce n'est pas le cas, pénaliser le serveur ou ignorer complètement la partie.

Je n'ai pas utilisé cette solution plus simple car elle ne fonctionne que pour des parties courtes avec un état final bien défini. Lorsqu'un jeu est potentiellement infini (comme c'est le cas avec les mondes autonomes (opens in a new tab)), vous avez besoin d'une solution qui prouve l'état sans le révéler.

En tant que tutoriel, cet article nécessitait un jeu court et facile à comprendre, mais cette technique est plus utile pour des jeux plus longs.

Pourquoi Zokrates ?

Zokrates (opens in a new tab) n'est pas la seule bibliothèque à divulgation nulle de connaissance disponible, mais il est similaire à un langage de programmation impératif (opens in a new tab) normal et prend en charge les variables booléennes.

Pour votre application, avec des exigences différentes, vous pourriez préférer utiliser Circum (opens in a new tab) ou Cairo (opens in a new tab).

Quand compiler Zokrates

Dans ce programme, nous compilons les programmes Zokrates à chaque démarrage du serveur (opens in a new tab). C'est clairement un gaspillage de ressources, mais il s'agit d'un tutoriel, optimisé pour la simplicité.

Si j'écrivais une application de niveau production, je vérifierais si j'ai un fichier avec les programmes Zokrates compilés pour cette taille de champ de mines, et si c'est le cas, je l'utiliserais. Il en va de même pour le déploiement d'un contrat vérificateur onchain.

Création des clés du vérificateur et du prouveur

La création de clés (opens in a new tab) est un autre calcul pur qui n'a pas besoin d'être effectué plus d'une fois pour une taille de champ de mines donnée. Encore une fois, cela n'est fait qu'une seule fois par souci de simplicité.

De plus, nous pourrions utiliser une cérémonie de configuration (opens in a new tab). L'avantage d'une cérémonie de configuration est que vous avez besoin soit de l'entropie, soit d'un résultat intermédiaire de chaque participant pour tricher sur la preuve à divulgation nulle de connaissance. Si au moins un participant à la cérémonie est honnête et supprime ces informations, les preuves à divulgation nulle de connaissance sont à l'abri de certaines attaques. Cependant, il n'y a aucun mécanisme pour vérifier que les informations ont été supprimées partout. Si les preuves à divulgation nulle de connaissance sont d'une importance critique, vous voudrez participer à la cérémonie de configuration.

Ici, nous nous appuyons sur les puissances perpétuelles de tau (opens in a new tab), qui ont compté des dizaines de participants. C'est probablement assez sûr, et beaucoup plus simple. Nous n'ajoutons pas non plus d'entropie pendant la création de la clé, ce qui permet aux utilisateurs de vérifier la configuration à divulgation nulle de connaissance plus facilement.

Où vérifier

Nous pouvons vérifier les preuves à divulgation nulle de connaissance soit onchain (ce qui coûte du gaz), soit dans le client (en utilisant verify (opens in a new tab)). J'ai choisi la première option, car cela vous permet de vérifier le vérificateur une fois, puis d'avoir confiance qu'il ne changera pas tant que l'adresse du contrat reste la même. Si la vérification était effectuée sur le client, vous devriez vérifier le code que vous recevez à chaque fois que vous téléchargez le client.

De plus, bien que ce jeu soit en solo, de nombreux jeux sur chaîne de blocs sont multijoueurs. La vérification onchain signifie que vous ne vérifiez la preuve à divulgation nulle de connaissance qu'une seule fois. Le faire dans le client obligerait chaque client à vérifier indépendamment.

Aplatir la carte dans TypeScript ou Zokrates ?

En général, lorsque le traitement peut être effectué soit dans TypeScript, soit dans Zokrates, il est préférable de le faire dans TypeScript, qui est beaucoup plus rapide et ne nécessite pas de preuves à divulgation nulle de connaissance. C'est la raison pour laquelle, par exemple, nous ne fournissons pas le hash à Zokrates pour lui faire vérifier qu'il est correct. Le hachage doit être effectué à l'intérieur de Zokrates, mais la correspondance entre le hash retourné et le hash onchain peut se faire à l'extérieur.

Cependant, nous continuons d'aplatir la carte dans Zokrates (opens in a new tab), alors que nous aurions pu le faire dans TypeScript. La raison est que les autres options sont, à mon avis, pires.

  • Fournir un tableau unidimensionnel de booléens au code Zokrates, et utiliser une expression telle que x*(height+2) +y pour obtenir la carte bidimensionnelle. Cela rendrait le code (opens in a new tab) un peu plus compliqué, j'ai donc décidé que le gain de performances n'en valait pas la peine pour un tutoriel.

  • Envoyer à Zokrates à la fois le tableau unidimensionnel et le tableau bidimensionnel. Cependant, cette solution ne nous apporte rien. Le code Zokrates devrait vérifier que le tableau unidimensionnel qui lui est fourni est bien la représentation correcte du tableau bidimensionnel. Il n'y aurait donc aucun gain de performances.

  • Aplatir le tableau bidimensionnel dans Zokrates. C'est l'option la plus simple, c'est pourquoi je l'ai choisie.

Où stocker les cartes

Dans cette application, gamesInProgress (opens in a new tab) est simplement une variable en mémoire. Cela signifie que si votre serveur s'arrête et doit être redémarré, toutes les informations qu'il a stockées sont perdues. Non seulement les joueurs ne peuvent pas continuer leur partie, mais ils ne peuvent même pas en commencer une nouvelle car le composant onchain pense qu'ils ont toujours une partie en cours.

C'est clairement une mauvaise conception pour un système en production, dans lequel vous stockeriez ces informations dans une base de données. La seule raison pour laquelle j'ai utilisé une variable ici est qu'il s'agit d'un tutoriel et que la simplicité est la considération principale.

Conclusion : Dans quelles conditions est-ce la technique appropriée ?

Vous savez donc maintenant comment écrire un jeu avec un serveur qui stocke un état secret qui n'a pas sa place onchain. Mais dans quels cas devriez-vous le faire ? Il y a deux considérations principales.

  • Jeu de longue durée : Comme mentionné ci-dessus, dans un jeu court, vous pouvez simplement publier l'état une fois le jeu terminé et tout faire vérifier à ce moment-là. Mais ce n'est pas une option lorsque le jeu prend un temps long ou indéfini, et que l'état doit rester secret.

  • Une certaine centralisation est acceptable : Les preuves à divulgation nulle de connaissance peuvent vérifier l'intégrité, qu'une entité ne falsifie pas les résultats. Ce qu'elles ne peuvent pas faire, c'est garantir que l'entité sera toujours disponible et répondra aux messages. Dans les situations où la disponibilité doit également être décentralisée, les preuves à divulgation nulle de connaissance ne sont pas une solution suffisante, et vous avez besoin du calcul multiparti (opens in a new tab).

Consultez ici d'autres de mes travaux (opens in a new tab).

Remerciements

  • Alvaro Alonso a lu un brouillon de cet article et a dissipé certains de mes malentendus concernant Zokrates.

Toute erreur restante est de ma responsabilité.