Privote : Créer une Dapp de vote privé multi-choix basée sur les preuves ZK

Dans ce tutoriel, nous implémentons un système de vote privé basé sur ZK, Privote, dans lequel personne ne peut voir les choix des votants avant la fin du vote.

8 min de lecture
Privote : Créer une Dapp de vote privé multi-choix basée sur les preuves ZK

Imaginez que vous êtes un électeur inscrit, appelé à voter sur une question particulière ou pour un candidat. Une partie externe souhaite vous acheter votre vote, autrement dit, elle veut que vous votiez selon son choix et non le vôtre, en échange de certaines contreparties. Si vous cédez à sa demande, elle aura besoin d'une preuve de votre vote — souvent sous la forme d'un reçu de transaction blockchain. Même si cela peut sembler une opportunité intéressante si vous êtes enclin à vendre votre vote, cela compromet l'intégrité de l'ensemble du processus électoral.

Comment permettre aux électeurs de voter pour les candidats ou les questions de leur choix tout en dissimulant leurs préférences afin que personne d'autre ne puisse les voir ? La réponse tient en un seul mot : le chiffrement.

Dans ce tutoriel, nous allons explorer comment développer une application de vote simple — Privote — qui utilise le chiffrement sur la blockchain. Le moment venu, les votes sont déchiffrés et comptabilisés, et les résultats sont publiés sur la blockchain tout en préservant l'anonymat des électeurs. Les résultats sont vérifiés grâce à une preuve à divulgation nulle de connaissance (ZK). Bien qu'imparfait, cet exemple illustre les concepts sous-jacents.

Veuillez consulter le billet de blog de Vitalik Buterin pour une compréhension approfondie du vote sur blockchain.

Avant de poursuivre, voici une présentation des principaux composants de la dapp Privote :

  • Smart Contract : Ce système stocke plusieurs votes (bulletins) et les données associées, telles que les titres, les choix disponibles et les données chiffrées.

  • Partie de confiance : Un serveur hors chaîne qui déchiffre les votes, calcule les résultats et les publie sur la blockchain. Ce serveur est appelé « Partie de confiance » pour une raison précise : il ne peut pas publier des résultats incorrects, que ce soit en calculant de manière inexacte ou en censurant des messages.

  • Frontend : Une plateforme où les utilisateurs connectent leurs wallets et soumettent leurs votes. Il y aura également une page distincte pour l'« administrateur », lui permettant de consulter le nombre de votes exprimés et de publier manuellement les résultats.

Pour construire Privote avec succès, nous devons relever deux défis :

  • Permettre à n'importe quel électeur de chiffrer son vote tout en permettant à la partie de confiance de le déchiffrer.

  • S'assurer que la partie de confiance est fiable et capable de fournir une preuve ZK du calcul, mais que prouvons-nous exactement ?

Défi 1 : Permettre à n'importe quel électeur de chiffrer son vote tout en permettant à la partie de confiance de le déchiffrer

Pour résoudre ce problème, nous pouvons adopter le chiffrement asymétrique. Contrairement au chiffrement symétrique, qui utilise la même clé pour chiffrer et déchiffrer les données, le chiffrement asymétrique utilise deux clés différentes : l'une pour chiffrer et l'autre pour déchiffrer. Il existe plusieurs algorithmes de chiffrement asymétrique, notamment ElGamal, RSA, DSA et les techniques à courbes elliptiques. Pour ce tutoriel, nous utiliserons les techniques à courbes elliptiques, plus précisément l'échange de clés Diffie-Hellman(ECDH)](https://www.upguard.com/blog/diffie-hellman)(ECDH)).

Dans ECDH, la partie de confiance et chaque électeur disposent de leurs propres paires de clés (une clé publique et une clé privée). Le choix de la courbe elliptique, comme la courbe Baby Jubjub utilisée ici pour son adéquation aux preuves à divulgation nulle de connaissance, influe sur la sécurité et l'efficacité des opérations cryptographiques. La courbe Baby Jubjub est définie par l'équation y^2 = x^3 + 168698 x 2 + x, et les paires de clés sont générées sous forme de paires de points spécifiques sur cette courbe :

import {
  packPoint,
  unpackPoint,
  mulPointEscalar,
  Point,
} from "@zk-kit/baby-jubjub";
import crypto from "crypto";

// generates babyjubjub key pair that is compatible with our zokrates implementation

export const generateKeyPair = () => {
// X and Y coordinates of the base point 
  const Base8: Point<bigint> = [
    16540640123574156134436876038791482806971768689494387082833631921987005038935n,
    20819045374670962167435360035096875258406992893633759881276124905556507972311n,
  ];

  const privateKey = BigInt(`0x${crypto.randomBytes(32).toString("hex")}`);

  const publicKey = packPoint(mulPointEscalar(Base8, privateKey));

  return {
    privateKey,
    publicKey,
  };
};
Copier

Dans la fonction generateKeyPair, je commence par créer une clé privée aléatoire sécurisée, puis je calcule la clé publique correspondante en effectuant une opération mathématique spécifique (multiplication scalaire) sur un point prédéfini de la courbe, souvent appelé point de base ou point générateur. Dans le code, ce point est nommé Base8. Le résultat de cette multiplication est un autre point sur la courbe, désigné par ses coordonnées X et Y. Ce point est ensuite formaté en bigint pour une utilisation pratique avec la fonction packPoint.

Voter avec un choix chiffré

L'électeur crée un cipher en combinant sa clé privée avec la clé publique de la partie de confiance. Ce cipher est utilisé pour chiffrer le vote. Le smart contract enregistre ensuite le vote chiffré et la clé publique de l'électeur ensemble.

export const encryptVote = async (vote: string) => {
  const { publicKey: trustedPartypublicKey } = config;
  const { privateKey, publicKey } = generateKeyPair();
  const encryptedVote = await encryptData(
    vote,
    privateKey,
    publicKey,
    BigInt(trustedPartypublicKey)
  );

  return {
    encryptedVote,
    publicKey: publicKey.toString(),
  };
};

export const encryptData = (
  data: string,
  privateKey: bigint,
  voterPublicKey: bigint,
  trustedPartyKey: bigint
) => {
  // Compute the shared secret by multiplying the trusted party's public key by the private key.
  const unpackedKey = unpackPoint(trustedPartyKey);
  if (!unpackedKey) {
    throw new Error("Invalid key");
  }
  const sharedSecretPoint = mulPointEscalar(unpackedKey, privateKey);

  let sharedSecret = sharedSecretPoint[0].toString(16);

  // Pad the shared secret with zeros to make it 32 bytes long.
  while (sharedSecret.length < 64) {
    sharedSecret = "0" + sharedSecret;
  }

  const cipher = crypto.createCipheriv(
    "aes-256-ctr",
    Buffer.from(sharedSecret, "hex"), // Use the first 32 bytes of the shared secret as the key.
    Buffer.alloc(16, 0) // Use a zero-filled buffer as the IV.
  );
  let encrypted = cipher.update(data, "utf8", "hex");
  encrypted += cipher.final("hex");

  return encrypted;
};
Copier

Déchiffrement des votes et calcul des résultats

La partie de confiance peut créer un cipher en utilisant sa clé privée et la clé publique de chaque électeur pour déchiffrer les données.

export const publishResults = async (id: number) => {
	.....
	.....

  const decryptedVotes = votes.map((vote) => {
    const { encryptedVote, voter } = vote;
    return decryptVote(encryptedVote, voter);
  });

  const results = calculateResult(decryptedVotes);
// next send the calculate results to the blockchain with the zk proof 
	 ......
	 ......
};

export const decryptVote = (encryptedVote: string, voterPublicKey: string) => {
  const { privateKey } = config;
  const decryptedVote = decryptData(
    encryptedVote,
    BigInt(privateKey),
    BigInt(voterPublicKey)
  );

  return decryptedVote;
};

export const decryptData = (
  encryptedData: string,
  privateKey: bigint,
  trustedPartyPublicKey: bigint
) => {
  const unpackedKey = unpackPoint(trustedPartyPublicKey);
  if (!unpackedKey) {
    throw new Error("Invalid key");
  }

  const sharedSecretPoint = mulPointEscalar(unpackedKey, privateKey);

  let sharedSecret = sharedSecretPoint[0].toString(16);

  // Pad the shared secret with zeros to make it 32 bytes long.
  while (sharedSecret.length < 64) {
    sharedSecret = "0" + sharedSecret;
  }

  const decipher = crypto.createDecipheriv(
    "aes-256-ctr",
    Buffer.from(sharedSecret, "hex"), // Use the first 32 bytes of the shared secret as the key.
    Buffer.alloc(16, 0) // Use a zero-filled buffer as the IV.
  );
  let decrypted = decipher.update(encryptedData, "hex", "utf8");
  decrypted += decipher.final("utf8");
  return decrypted;
};
Copier

Dans la fonction generateKeyPair, nous avons formaté la clé publique pour une utilisation facile avec la fonction packPoint. Dans le code ci-dessus, avec unpackPoint, nous la reconvertissons dans sa forme originale, car nous devons créer un secret partagé en combinant la clé privée et la clé publique de l'électeur. Ce secret partagé est utilisé pour déchiffrer le vote.

Après avoir déchiffré chaque vote, le calcul des résultats est simple ! Il suffit de parcourir le tableau des votes et d'accumuler les votes pour un choix donné.

Défi 2 : S'assurer que la partie de confiance est fiable et capable de fournir une preuve ZK du calcul. Mais que prouvons-nous exactement ?

Si vous n'êtes pas familier avec les preuves ZK, il s'agit d'une technologie assez complexe mais fascinante. Les preuves ZK sont une méthode par laquelle une partie peut prouver à une autre qu'elle connaît une valeur sans révéler aucune information sur cette valeur elle-même. En substance, c'est un moyen de valider la véracité de quelque chose sans en divulguer les détails. De plus, n'importe qui peut vérifier si cette preuve est correcte.

Pour notre application, idéalement, la partie de confiance devrait prouver qu'elle peut prendre les votes chiffrés, les déchiffrer et calculer le nombre de votes reçus par chaque choix. Dans ce cas, la clé « secrète » est la clé privée de la partie de confiance. Par souci de simplicité, nous prouvons simplement la possession d'une clé privée correspondant à la clé publique enregistrée dans le smart contract. Comme mentionné précédemment, les votes utilisent cette clé pour générer un cipher afin de chiffrer le vote.

À cette fin, nous utiliserons ZoKrates, une boîte à outils pour les zkSNARKs sur Ethereum. Elle vous aide à utiliser le calcul vérifiable dans votre dapp, depuis la spécification de votre programme dans un langage de haut niveau jusqu'à la génération de preuves de calcul et leur vérification dans Solidity.

Voici le programme ZK simple que j'utilise :

import "ecc/proofOfOwnership" as proofOfOwnership;
from "ecc/babyjubjubParams" import BabyJubJubParams;

def main(field[2] pk, private field sk, BabyJubJubParams context) -> bool {
  bool resp = proofOfOwnership(pk, sk,context);
  return resp;
}
Copier

Comme vous pouvez le voir dans la liste des arguments, pk est un argument public et sk est un argument privé. context est également public. Seul le prouveur — la partie de confiance — connaît sk, mais pk est connu de tous car il figure dans le smart contract. context représente les paramètres liés à la courbe que nous avons choisie pour générer une paire de clés.

import { initialize } from "zokrates-js";

export const generateProof = async (
  privateKey: string,
  publicKey: string[]
) => {

  const provider = await initialize();

  const artifacts = await getZKP();

  const program = Uint8Array.from(Buffer.from(artifacts.program, "hex"));

  const { witness, output } = provider.computeWitness(program, [
    ...publicKey.flat(),
    privateKey,
    "8",
    "168700",
    "168696",
    "168698",
    "1",
    "0",
    "1",
  "16540640123574156134436876038791482806971768689494387082833631921987005038935",
    "20819045374670962167435360035096875258406992893633759881276124905556507972311",
  ]);

  const provingKey = Uint8Array.from(
    Buffer.from(artifacts.provingKey, "hex")
  );

  const proof = provider.generateProof(
    program,
    witness,
    provingKey
  );

  return provider.utils.formatProof(proof)
};
Copier

Dans l'extrait ci-dessus, j'utilise le SDK JS de ZoKrates pour générer une preuve. Le flux est similaire à ce qui est documenté dans la documentation ZoKrates. Notre objectif est d'utiliser le programme ZoKrates pour générer une preuve ; nous passons donc trois arguments à la fonction computeWitness : pk (un argument public), sk (un argument privé) et context. Ceux-ci sont utilisés pour créer un témoin (witness), qui est ensuite utilisé pour générer la preuve.

Vous vous interrogez peut-être sur cette partie de l'argument :

"8",
    "168700",
    "168696",
    "168698",
    "1",
    "0",
    "1",
  "16540640123574156134436876038791482806971768689494387082833631921987005038935",
    "20819045374670962167435360035096875258406992893633759881276124905556507972311"
Copier

Le context fournit les données relatives à la courbe Baby JubJub spécifique que nous avons utilisée tout au long de l'application, car de nombreuses variantes de courbes peuvent être employées. Les fichiers tels que la clé de vérification, la clé de preuve et le programme ZoKrates sont conservés dans un répertoire séparé. J'utilise la fonction ci-dessous pour les récupérer :

const getZKP = async () => {
  const zkPath = path.join(process.cwd(), "src", "services", "zkp");
  const program = (await readFile(path.join(zkPath, "./out"))).toString("hex");
  const verificationKey = JSON.parse(
    (await readFile(path.join(zkPath, "./verification.key"))).toString()
  );
  const provingKey = (
    await readFile(path.join(zkPath, "./proving.key"))
  ).toString("hex");

  return {
    program,
    provingKey,
    verificationKey,
  };
};
Copier

L'extrait ci-dessous est tiré du contrat dans lequel celui-ci accepte les résultats calculés par la partie de confiance. Le contrat n'accepte le résultat que si la vérification est réussie. Les parties externes peuvent vérifier cela en passant la pk (qui est publique et figure dans le contrat) et la preuve à la fonction de vérification :

        if (!isVerified) {
            revert("Invalid proof");
        }

     // accept results
Copier

Et maintenant ?

Dans ce billet de blog, nous avons développé Privote, un système de vote privé basé sur ZK qui préserve la confidentialité des choix des électeurs jusqu'à la fin de la période de vote. Nous avons utilisé des techniques à courbes elliptiques pour chiffrer les votes et démontré comment les déchiffrer et les comptabiliser de manière sécurisée. Nous avons également mis en œuvre des preuves à divulgation nulle de connaissance à l'aide de ZoKrates pour vérifier l'intégrité du processus de vote sans révéler d'informations privées.

Ce tutoriel offre une compréhension de base de la création de systèmes de vote sécurisés et privés sur la blockchain. Dans la deuxième partie, nous construirons le frontend et vous montrerons comment les utilisateurs peuvent se connecter et voter en utilisant le MetaMask SDK.

Traduit par IA. Peut contenir des erreurs. Veuillez toujours vérifier les informations.

Évaluer la traduction