Saltar al contenido

Privote: Programando una Dapp de votación privada multi-opción basada en pruebas ZK

En este tutorial, implementamos un sistema de votación privada basado en ZK, Privote, en el que nadie puede ver las elecciones de los votantes hasta que finalice la votación

8 min de lectura
Privote: Programando una Dapp de votación privada multi-opción basada en pruebas ZK

Imagina que eres un votante registrado que vota sobre un tema o candidato en particular. Una parte externa quiere que vendas tu voto; es decir, quiere que votes por su opción, no por la tuya, a cambio de ciertos incentivos. Si cedes a su demanda, necesitarán una prueba de tu voto, generalmente a través de un recibo de transacción en la blockchain. Aunque esto podría parecer una oportunidad atractiva si estás dispuesto a vender tu voto, socava la integridad de todo el proceso electoral.

¿Cómo podemos permitir que los votantes elijan a sus candidatos o temas preferidos manteniendo sus elecciones en secreto para que nadie más pueda verlas? La respuesta se resume en una sola palabra: Cifrado.

En este tutorial, exploraremos cómo desarrollar una aplicación de votación sencilla —Privote— que utiliza cifrado en la blockchain. Cuando llegue el momento, los votos se descifran y contabilizan, y los resultados se publican de vuelta en la blockchain preservando el anonimato de los votantes. Los resultados se verifican mediante una prueba de conocimiento cero (ZK). Aunque no es perfecta, este ejemplo ilustra los conceptos fundamentales.

Consulta la entrada del blog de Vitalik Buterin para una comprensión más profunda sobre la votación en blockchain.

Antes de continuar, aquí tienes un desglose de los componentes principales de la dapp Privote:

  • Contrato inteligente: Este sistema almacena múltiples votos (papeletas) y datos relacionados, como títulos, opciones disponibles y datos cifrados.

  • Parte de confianza: Un servidor fuera de la cadena que descifra los votos, calcula los resultados y los publica en la blockchain. Este servidor recibe el nombre de "Parte de confianza" por una razón: no puede publicar resultados incorrectos, ya sea por cálculos inexactos o por censurar mensajes.

  • Frontend: Una plataforma donde los usuarios conectan sus wallets y envían sus votos. También habrá una página separada para que el "administrador" vea el número de votos emitidos y publique los resultados manualmente.

Para construir Privote con éxito, necesitamos abordar dos desafíos:

  • ¿Cómo permitir que cualquier votante cifre su voto mientras la parte de confianza puede descifrarlo?

  • ¿Cómo garantizar que la parte de confianza sea fiable y pueda proporcionar una prueba ZK del cálculo? ¿Pero qué estamos probando exactamente?

Desafío 1: Permitir que cualquier votante cifre su voto mientras la parte de confianza puede descifrarlo

Para resolver esto, podemos adoptar el cifrado asimétrico. A diferencia del cifrado simétrico, que utiliza la misma clave para cifrar y descifrar datos, el cifrado asimétrico usa dos claves diferentes: una para cifrar y otra para descifrar. Existen múltiples algoritmos de cifrado asimétrico, entre ellos ElGamal, RSA, DSA y técnicas de curva elíptica. Para este tutorial, utilizaremos las técnicas de curva elíptica, específicamente el intercambio de claves Diffie-Hellman(ECDH)](https://www.upguard.com/blog/diffie-hellman)(ECDH)).

En ECDH, tanto la parte de confianza como cada votante tienen sus propios pares de claves (una clave pública y una clave privada). La elección de la curva elíptica, como la curva Baby Jubjub utilizada aquí por su idoneidad en las pruebas de conocimiento cero, influye en la seguridad y eficiencia de las operaciones criptográficas. La curva Baby Jubjub está definida por la ecuación y^2 = x^3 + 168698 x 2 + x, y los pares de claves se generan como pares específicos de puntos en esta curva:

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

En la función generateKeyPair, primero creo una clave privada aleatoria segura y luego calculo la clave pública correspondiente realizando una operación matemática específica (multiplicación escalar) sobre un punto predefinido en la curva, denominado habitualmente punto base o punto generador. En el código, este punto se llama Base8. El resultado de esta multiplicación es otro punto en la curva, representado por sus coordenadas X e Y. Posteriormente, el punto se formatea en un bigint para facilitar su uso con la función packPoint.

Votación con elección cifrada

El votante crea un cifrado combinando su clave privada con la clave pública de la parte de confianza. Este cifrado se utiliza para encriptar el voto. El contrato inteligente guarda entonces el voto cifrado junto con la clave pública del votante.

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

Descifrado de votos y cálculo de resultados

La parte de confianza puede crear un cifrado usando su clave privada y la clave pública de cada votante para descifrar los datos.

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

En la función generateKeyPair, formateamos la clave pública para facilitar su uso con la función packPoint. En el código anterior, con unpackPoint, la convertimos de vuelta a su forma original porque necesitamos crear un secreto compartido combinando la clave privada y la clave pública del votante. El secreto compartido se utiliza para descifrar el voto.

Después de descifrar cada voto, ¡calcular los resultados es sencillo! Solo hay que recorrer el array de votos y acumular los votos para cada opción.

Desafío 2: Garantizar que la parte de confianza sea fiable y pueda proporcionar una prueba ZK del cálculo. ¿Pero qué estamos probando exactamente?

Si no estás familiarizado con las pruebas ZK, se trata de una tecnología bastante compleja pero fascinante. Las pruebas ZK son un método mediante el cual una parte puede demostrar a otra que conoce un valor sin revelar ninguna información sobre el valor en sí. En esencia, es una forma de validar la veracidad de algo sin compartir los detalles. Además, cualquiera puede verificar si esa prueba es correcta.

Para nuestra aplicación, idealmente, la parte de confianza debería demostrar que puede tomar los votos cifrados, descifrarlos y calcular el número de votos que ha recibido cada opción. En este caso, la clave "secreta" es la clave privada de la parte de confianza. Por simplicidad, solo estamos probando la posesión de una clave privada que coincide con la clave pública registrada en el contrato inteligente. Como se mencionó anteriormente, los votos utilizan esa clave para generar un cifrado que encripta el voto.

Para este propósito, utilizaremos ZoKrates, una caja de herramientas para zkSNARKs en Ethereum. Te ayuda a utilizar la computación verificable en tu dapp, desde especificar tu programa en un lenguaje de alto nivel hasta generar pruebas de cómputo y verificar esas pruebas en Solidity.

Este es el sencillo programa ZK que utilizo:

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

Como puedes ver en la lista de argumentos, pk es un argumento público y sk es un argumento privado. context también es público. Solo el probador —la parte de confianza— conoce el sk, pero el pk es conocido por cualquiera, ya que está en el contrato inteligente. context son los parámetros relacionados con la curva que hemos elegido para generar un par de claves.

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

En el fragmento anterior, estoy usando el SDK de ZoKrates para JS para generar una prueba. El flujo es similar al que se documenta en la documentación de ZoKrates. Nuestro objetivo es usar el programa ZoKrates para generar una prueba, por lo que pasamos tres argumentos a la función computeWitness: pk (un argumento público), sk (un argumento privado) y context. Estos se utilizan para crear un testigo (witness), que luego se usa para generar la prueba.

Puede que te preguntes sobre esta parte del argumento:

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

El context proporciona los datos relacionados con la curva Baby JubJub específica que hemos estado usando a lo largo de la aplicación, ya que se pueden utilizar muchas variaciones de curvas. Archivos como la clave de verificación, la clave de prueba y el programa ZoKrates se guardan en un directorio separado. Utilizo la siguiente función para obtenerlos:

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

El fragmento a continuación pertenece al contrato, en el que este acepta los resultados calculados por la parte de confianza. Solo si la verificación es exitosa el contrato acepta el resultado. Los usuarios externos pueden verificar esto pasando el pk (que es público y se encuentra en el contrato) y la prueba a la función verificadora:

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

     // accept results
Copiar

¿Qué sigue?

En este blog, desarrollamos Privote, un sistema de votación privada basado en ZK que mantiene las elecciones de los votantes en secreto hasta que finaliza el período de votación. Utilizamos técnicas de curva elíptica para cifrar los votos y demostramos cómo descifrarlos y contabilizarlos de forma segura. También implementamos pruebas de conocimiento cero usando ZoKrates para verificar la integridad del proceso de votación sin revelar información privada.

Este tutorial ofrece una comprensión básica de cómo crear sistemas de votación seguros y privados en la blockchain. En la segunda parte, construiremos el frontend y te mostraremos cómo los usuarios pueden conectarse y emitir votos usando el MetaMask SDK.

Traducido por IA. Puede contener errores. Por favor, verifique siempre la información.

Califica la traducción

Este artículo fue escrito por: