Privote: Coding a ZK Proof-Based Private Multi-Choice Voting Dapp

In this tutorial, we implement a ZK-based private voting system, Privote, in which no one can see the voters' choices until the voting has ended

by Chinthaka WeerakkodyJune 10, 2024
Privote feature image

Imagine you are a registered voter voting on a particular issue or candidate. An external party wants you to sell your vote, in other words, they want you to vote for their choice, not your own, in exchange for some incentives. They will need proof of your vote if you succumb to their demand—often through a blockchain transaction receipt. While this might seem like an excellent opportunity if you're inclined to sell your vote, it undermines the integrity of the entire voting process.

How can we enable voters to vote for their preferred candidates or issues while concealing their choices so that no one else can see them? The answer lies in one word: Encryption.

In this tutorial, we’ll explore how to develop a simple voting application—Privote—that utilizes encryption on the blockchain. When the time arrives, the votes are decrypted and tallied, and the results are published back to the blockchain while maintaining the voters' anonymity. The results are verified through a zero-knowledge (ZK) proof. Although not perfect, this example illustrates the underlying concepts.

Please refer to Vitalik Buterin's blog post for an in-depth understanding of blockchain voting.

Before we proceed, here is a breakdown of the main components of the Privote dapp:

  • Smart Contract: This system stores multiple votes (ballots) and related data such as titles, available choices, and encrypted data.
  • Trusted Party: An off-chain server that decrypts the votes, calculates the results and publishes them on the blockchain. This server is termed the "Trusted Party" for a reason: it cannot publish incorrect results, either by computing inaccurately or by censoring messages.
  • Frontend: A platform where users connect their wallets and submit votes. There will also be a separate page for the "admin" to view the number of votes cast and manually release the results.

To successfully build out Privote, we need to address two challenges:

  • Enable any voter to encrypt their vote while allowing the trusted party to decrypt it?
  • Ensure the trusted party is reliable and can provide a ZK proof of the calculation, but what exactly are we proving?

Challenge 1: Enable any voter to encrypt their vote while allowing the trusted party to decrypt it


To solve this, we can adopt asymmetric encryption. Unlike symmetric encryption, which uses the same key to encrypt and decrypt data, asymmetric encryption uses two different keys, one to encrypt and the other to decrypt. Multiple asymmetric encryption algorithms exist, including ElGamal, RSA, DSA, and elliptic curve techniques. For this tutorial, we’ll use the elliptic curve techniques, specifically the [Diffie-Hellman key exchange](https://www.upguard.com/blog/diffie-hellman)(ECDH).

In ECDH, both the trusted party and each voter have their own key pairs (a public key and a private key), and the choice of elliptic curve, such as the Baby Jubjub curve used here for its suitability in zero-knowledge proofs, impacts the security and efficiency of cryptographic operations; the Baby Jubjub curve is defined by the equation y^2 = x^3 + 168698 x 2 + x, and key pairs are generated as specific pairs of points on this curve:

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

In the generateKeyPair function, I first create a secure random private key and then calculate the corresponding public key by performing a specific mathematical operation (scalar multiplication) on a predefined point on the curve, often called a base point or generator point. In the code, this point is named Base8. The result of this multiplication is another point on the curve, denoted by its X and Y coordinates. Subsequently, the point is formatted into a bigint for convenient use with the packPoint function.

Voting with decrypted choice

The voter creates a cipher by combining their private key with the trusted party's public key. This cipher is used to encrypt the vote. The smart contract then saves the encrypted vote and the voter's public key together.

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

Decrypting votes and calculating results

The trusted party can create a cipher using their private key and each voter's public key to decrypt the data.

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

In the generateKeyPair function, we formatted the public key for easy use with the packPoint function. In the above code, with unpackPoint, we convert it back to its original form because we need to create a shared secret by combining the private key and the voter's public key. The shared secret is used to decrypt the vote.

After decrypting each vote, calculating the results is easy! Just loop through the votes array and accumulate votes for a given choice.

Challenge 2: Ensure the trusted party is reliable and can provide ZK proof of the calculation. But what exactly are we proving?


If you're unfamiliar with ZK proofs, they are a fairly complex yet fascinating piece of technology. ZK proofs are a method whereby one party can prove to another that they know a value without revealing any information about the value itself. Essentially, it's a way to validate the truth of something without sharing the details. Moreover, anyone can verify whether that proof is correct.

For our application, ideally, the trusted party should prove that they can take the encrypted votes, decrypt them, and calculate the number of votes each choice has received. In this case, the “secret” key is the trusted party's private key. For the sake of simplicity, we are just proving the ownership of a private key that matches the public key committed in the smart contract. As mentioned earlier, the votes use that key to generate a cipher to encrypt the vote.

For this purpose, we’ll use ZoKrates, a toolbox for zkSNARKs on Ethereum. It helps you utilize verifiable computation in your dapp, from specifying your program in a high-level language to generating proofs of computation and verifying those proofs in Solidity.

This is the simple ZK program I use:

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

As you can see in the arguments list, pk is a public argument, and sk is a private argument. context is also public. Only the prover - the trusted party, knows the sk, but the pk is known to anyone as it is in the smart contract. context is the parameters related to the curve we have picked to generate a key pair.

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

In the above snippet, I am using the ZoKrates JS SDK to generate a proof. The flow is similar to what is documented in the ZoKrates documentation. Our goal is to use the ZoKrates program to generate a proof, so we’re passing three arguments to the computeWitness function: pk (a public argument), sk (a private argument), and context. These are used to create a witness, which is then used to generate proof.

You might be wondering about this part of the argument:

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

The context provides the data related to the specific Baby JubJub curve we've been using throughout the application, as many curve variations can be used. Files such as the verification key, proving key and ZoKrates program are kept in a separate directory. I am using the function below to get those:

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

The snippet below is from the contract in which the contract accepts the results calculated by the trusted party. Only if the verification is successful does the contract accept the result. Outsiders can verify this by passing the pk (which is public and found in the contract) and the proof to the verifier function:

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

     // accept results

What’s next?

In this blog, we developed Privote, a ZK-based private voting system that keeps voters' choices confidential until the voting period ends. We used elliptic curve techniques to encrypt votes and demonstrated how to decrypt and tally them securely. We also implemented zero-knowledge proofs using ZoKrates to verify the voting process's integrity without revealing private information.

This tutorial provides a basic understanding of creating secure and private voting systems on the blockchain. In the second part, we’ll build out the frontend and show you how users can connect and cast votes using the MetaMask SDK.

Receive our Newsletter