Privote: Entwicklung einer ZK-Proof-basierten privaten Multi-Choice-Voting-Dapp

In diesem Tutorial implementieren wir ein ZK-basiertes privates Abstimmungssystem, Privote, bei dem niemand die Entscheidungen der Wähler sehen kann, bis die Abstimmung beendet ist.

8 Min. Lesezeit
Privote: Entwicklung einer ZK-Proof-basierten privaten Multi-Choice-Voting-Dapp

Stellen Sie sich vor, Sie sind ein registrierter Wähler, der über ein bestimmtes Thema oder einen Kandidaten abstimmt. Eine externe Partei möchte, dass Sie Ihre Stimme verkaufen – mit anderen Worten: Sie soll für deren Wahl stimmen, nicht für Ihre eigene, im Austausch gegen bestimmte Anreize. Wenn Sie dieser Forderung nachgeben, benötigt die Partei einen Nachweis Ihrer Stimmabgabe – häufig in Form einer Blockchain-Transaktionsquittung. Auch wenn dies wie eine verlockende Gelegenheit erscheinen mag, wenn Sie geneigt sind, Ihre Stimme zu verkaufen, untergräbt es die Integrität des gesamten Wahlprozesses.

Wie können wir es Wählern ermöglichen, für ihre bevorzugten Kandidaten oder Themen zu stimmen und dabei ihre Entscheidungen zu verbergen, sodass niemand sonst sie einsehen kann? Die Antwort liegt in einem einzigen Wort: Verschlüsselung.

In diesem Tutorial erfahren Sie, wie Sie eine einfache Wahlapplikation – Privote – entwickeln, die Verschlüsselung auf der Blockchain nutzt. Zum gegebenen Zeitpunkt werden die Stimmen entschlüsselt und ausgezählt, und die Ergebnisse werden unter Wahrung der Anonymität der Wähler wieder auf der Blockchain veröffentlicht. Die Ergebnisse werden durch einen Zero-Knowledge (ZK) Proof verifiziert. Obwohl nicht perfekt, veranschaulicht dieses Beispiel die zugrundeliegenden Konzepte.

Für ein tiefgehendes Verständnis des Blockchain-Votings empfehlen wir Vitalik Buterins Blogbeitrag.

Bevor wir fortfahren, hier ein Überblick über die Hauptkomponenten der Privote-Dapp:

  • Smart Contract: Dieses System speichert mehrere Stimmen (Stimmzettel) und zugehörige Daten wie Titel, verfügbare Auswahlmöglichkeiten und verschlüsselte Daten.

  • Vertrauenswürdige Partei: Ein Off-Chain-Server, der die Stimmen entschlüsselt, die Ergebnisse berechnet und sie auf der Blockchain veröffentlicht. Dieser Server wird aus gutem Grund als „Vertrauenswürdige Partei" bezeichnet: Er kann keine falschen Ergebnisse veröffentlichen, weder durch fehlerhafte Berechnungen noch durch das Zensieren von Nachrichten.

  • Frontend: Eine Plattform, auf der Nutzer ihre Wallets verbinden und Stimmen abgeben. Es wird auch eine separate Seite für den „Admin" geben, um die Anzahl der abgegebenen Stimmen einzusehen und die Ergebnisse manuell freizugeben.

Um Privote erfolgreich aufzubauen, müssen wir zwei Herausforderungen bewältigen:

  • Wie können wir jedem Wähler ermöglichen, seine Stimme zu verschlüsseln, während die vertrauenswürdige Partei sie entschlüsseln kann?

  • Wie stellen wir sicher, dass die vertrauenswürdige Partei zuverlässig ist und einen ZK-Proof der Berechnung liefern kann – aber was genau beweisen wir dabei?

Herausforderung 1: Jedem Wähler ermöglichen, seine Stimme zu verschlüsseln, während die vertrauenswürdige Partei sie entschlüsseln kann

Um dies zu lösen, können wir asymmetrische Verschlüsselung einsetzen. Im Gegensatz zur symmetrischen Verschlüsselung, die denselben Schlüssel zum Ver- und Entschlüsseln von Daten verwendet, nutzt die asymmetrische Verschlüsselung zwei verschiedene Schlüssel: einen zum Verschlüsseln und einen zum Entschlüsseln. Es gibt mehrere asymmetrische Verschlüsselungsalgorithmen, darunter ElGamal, RSA, DSA und Elliptische-Kurven-Verfahren. Für dieses Tutorial verwenden wir die Elliptische-Kurven-Verfahren, konkret den Diffie-Hellman-Schlüsselaustausch(ECDH)](https://www.upguard.com/blog/diffie-hellman)(ECDH)).

Bei ECDH verfügen sowohl die vertrauenswürdige Partei als auch jeder Wähler über eigene Schlüsselpaare (einen öffentlichen und einen privaten Schlüssel). Die Wahl der elliptischen Kurve – wie die hier verwendete Baby Jubjub-Kurve, die aufgrund ihrer Eignung für Zero-Knowledge-Proofs gewählt wurde – beeinflusst die Sicherheit und Effizienz kryptografischer Operationen. Die Baby Jubjub-Kurve ist durch die Gleichung y^2 = x^3 + 168698 x 2 + x definiert, und Schlüsselpaare werden als spezifische Punktepaare auf dieser Kurve generiert:

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,
  };
};
Kopieren

In der Funktion generateKeyPair erstelle ich zunächst einen sicheren zufälligen privaten Schlüssel und berechne anschließend den entsprechenden öffentlichen Schlüssel durch eine spezifische mathematische Operation (Skalarmultiplikation) auf einem vordefinierten Punkt der Kurve, der häufig als Basispunkt oder Generatorpunkt bezeichnet wird. Im Code trägt dieser Punkt den Namen Base8. Das Ergebnis dieser Multiplikation ist ein weiterer Punkt auf der Kurve, dargestellt durch seine X- und Y-Koordinaten. Anschließend wird der Punkt mithilfe der Funktion packPoint zur bequemen Verwendung in ein bigint umgewandelt.

Abstimmen mit verschlüsselter Wahl

Der Wähler erstellt einen Cipher, indem er seinen privaten Schlüssel mit dem öffentlichen Schlüssel der vertrauenswürdigen Partei kombiniert. Dieser Cipher wird zur Verschlüsselung der Stimme verwendet. Der Smart Contract speichert anschließend die verschlüsselte Stimme zusammen mit dem öffentlichen Schlüssel des Wählers.

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;
};
Kopieren

Stimmen entschlüsseln und Ergebnisse berechnen

Die vertrauenswürdige Partei kann einen Cipher mithilfe ihres privaten Schlüssels und des öffentlichen Schlüssels jedes Wählers erstellen, um die Daten zu entschlüsseln.

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;
};
Kopieren

In der Funktion generateKeyPair haben wir den öffentlichen Schlüssel für die einfache Verwendung mit der packPoint-Funktion formatiert. Im obigen Code konvertieren wir ihn mit unpackPoint zurück in seine ursprüngliche Form, da wir ein gemeinsames Geheimnis durch die Kombination des privaten Schlüssels mit dem öffentlichen Schlüssel des Wählers erstellen müssen. Dieses gemeinsame Geheimnis wird zur Entschlüsselung der Stimme verwendet.

Nach der Entschlüsselung jeder Stimme ist die Berechnung der Ergebnisse einfach! Iterieren Sie einfach durch das Stimmen-Array und summieren Sie die Stimmen für eine bestimmte Auswahl.

Herausforderung 2: Sicherstellen, dass die vertrauenswürdige Partei zuverlässig ist und einen ZK-Proof der Berechnung liefern kann. Aber was genau beweisen wir?

Falls Sie mit ZK-Proofs nicht vertraut sind: Es handelt sich um eine recht komplexe, aber faszinierende Technologie. ZK-Proofs sind eine Methode, bei der eine Partei einer anderen beweisen kann, dass sie einen Wert kennt, ohne dabei Informationen über den Wert selbst preiszugeben. Im Wesentlichen ist es eine Möglichkeit, die Wahrheit von etwas zu validieren, ohne die Details zu teilen. Darüber hinaus kann jeder überprüfen, ob dieser Proof korrekt ist.

Für unsere Applikation sollte die vertrauenswürdige Partei idealerweise beweisen, dass sie die verschlüsselten Stimmen entgegennehmen, entschlüsseln und die Anzahl der Stimmen für jede Auswahl berechnen kann. In diesem Fall ist der „geheime" Schlüssel der private Schlüssel der vertrauenswürdigen Partei. Der Einfachheit halber beweisen wir lediglich den Besitz eines privaten Schlüssels, der mit dem im Smart Contract hinterlegten öffentlichen Schlüssel übereinstimmt. Wie bereits erwähnt, verwenden die Stimmen diesen Schlüssel, um einen Cipher zur Verschlüsselung der Stimme zu generieren.

Für diesen Zweck verwenden wir ZoKrates, eine Toolbox für zkSNARKs auf Ethereum. Es hilft Ihnen, verifizierbare Berechnungen in Ihrer Dapp zu nutzen – von der Spezifikation Ihres Programms in einer Hochsprache bis hin zur Generierung von Berechnungsbeweisen und deren Verifizierung in Solidity.

Dies ist das einfache ZK-Programm, das ich verwende:

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;
}
Kopieren

Wie Sie in der Argumentenliste sehen können, ist pk ein öffentliches Argument und sk ein privates Argument. context ist ebenfalls öffentlich. Nur der Prover – die vertrauenswürdige Partei – kennt den sk, während pk für jeden zugänglich ist, da er im Smart Contract hinterlegt ist. context enthält die Parameter der Kurve, die wir zur Generierung eines Schlüsselpaars ausgewählt haben.

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)
};
Kopieren

Im obigen Codeausschnitt verwende ich das ZoKrates JS SDK, um einen Proof zu generieren. Der Ablauf ähnelt dem, was in der ZoKrates-Dokumentation beschrieben ist. Unser Ziel ist es, das ZoKrates-Programm zur Generierung eines Proofs zu verwenden. Daher übergeben wir der Funktion computeWitness drei Argumente: pk (ein öffentliches Argument), sk (ein privates Argument) und context. Diese werden verwendet, um einen Witness zu erstellen, der anschließend zur Generierung des Proofs genutzt wird.

Möglicherweise fragen Sie sich, was es mit diesem Teil des Arguments auf sich hat:

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

Der context liefert die Daten zur spezifischen Baby JubJub-Kurve, die wir in der gesamten Applikation verwendet haben, da viele Kurven-Variationen eingesetzt werden können. Dateien wie der Verifikationsschlüssel, der Proving Key und das ZoKrates-Programm werden in einem separaten Verzeichnis gespeichert. Ich verwende die folgende Funktion, um diese abzurufen:

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,
  };
};
Kopieren

Der folgende Ausschnitt stammt aus dem Contract, in dem der Contract die von der vertrauenswürdigen Partei berechneten Ergebnisse akzeptiert. Nur wenn die Verifizierung erfolgreich ist, akzeptiert der Contract das Ergebnis. Außenstehende können dies überprüfen, indem sie den pk (der öffentlich ist und im Contract zu finden ist) sowie den Proof an die Verifier-Funktion übergeben:

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

     // accept results
Kopieren

Wie geht es weiter?

In diesem Blogbeitrag haben wir Privote entwickelt, ein ZK-basiertes privates Wahlsystem, das die Entscheidungen der Wähler bis zum Ende des Abstimmungszeitraums vertraulich hält. Wir haben Elliptische-Kurven-Verfahren zur Verschlüsselung von Stimmen eingesetzt und gezeigt, wie diese sicher entschlüsselt und ausgezählt werden können. Außerdem haben wir Zero-Knowledge-Proofs mithilfe von ZoKrates implementiert, um die Integrität des Wahlprozesses zu verifizieren, ohne private Informationen preiszugeben.

Dieses Tutorial vermittelt ein grundlegendes Verständnis für die Erstellung sicherer und privater Wahlsysteme auf der Blockchain. Im zweiten Teil werden wir das Frontend aufbauen und zeigen, wie Nutzer sich verbinden und Stimmen mit dem MetaMask SDK abgeben können.

KI-übersetzt. Kann Fehler enthalten. Bitte überprüfen Sie die Informationen stets.

Bewerten Sie die Übersetzung