Privote: разработка децентрализованного приложения для частного голосования с несколькими вариантами на основе ZK-доказательств

В этом руководстве мы реализуем систему приватного голосования на основе ZK — Privote, в которой никто не видит выборы участников до завершения голосования.

8 мин чтения
Privote: разработка децентрализованного приложения для частного голосования с несколькими вариантами на основе ZK-доказательств

Представьте, что вы зарегистрированный избиратель, голосующий по определённому вопросу или за конкретного кандидата. Некая третья сторона хочет купить ваш голос — иными словами, предлагает вам проголосовать за её выбор, а не за свой, в обмен на определённые стимулы. Если вы поддадитесь на это требование, им понадобится подтверждение вашего голоса — как правило, в виде квитанции о транзакции в блокчейне. Хотя это может показаться выгодной возможностью, если вы склонны продать свой голос, подобная практика подрывает целостность всего избирательного процесса.

Как обеспечить избирателям возможность голосовать за предпочтительных кандидатов или по интересующим вопросам, скрывая их выбор так, чтобы никто другой не мог его увидеть? Ответ кроется в одном слове: шифрование.

В этом руководстве мы рассмотрим, как разработать простое приложение для голосования — Privote, — использующее шифрование в блокчейне. В нужный момент голоса расшифровываются и подсчитываются, а результаты публикуются обратно в блокчейн с сохранением анонимности избирателей. Результаты верифицируются с помощью доказательства с нулевым разглашением (ZK). Хотя этот пример не является совершенным, он наглядно иллюстрирует лежащие в его основе концепции.

Для более глубокого понимания голосования в блокчейне обратитесь к записи в блоге Виталика Бутерина.

Прежде чем продолжить, рассмотрим основные компоненты dapp Privote:

  • Смарт-контракт: система, хранящая множество голосов (бюллетеней) и связанные данные — заголовки, доступные варианты выбора и зашифрованные данные.

  • Доверенная сторона: внецепочечный сервер, который расшифровывает голоса, вычисляет результаты и публикует их в блокчейне. Этот сервер называется «доверенной стороной» не случайно: он не может публиковать некорректные результаты — ни вследствие неверных вычислений, ни путём цензурирования сообщений.

  • Фронтенд: платформа, где пользователи подключают кошельки и отправляют голоса. Также будет отдельная страница для «администратора», позволяющая просматривать количество поданных голосов и вручную публиковать результаты.

Для успешного создания Privote необходимо решить две задачи:

  • Как дать любому избирателю возможность зашифровать свой голос, при этом позволив доверенной стороне его расшифровать?

  • Как обеспечить надёжность доверенной стороны и её способность предоставить ZK-доказательство вычислений — но что именно мы доказываем?

Задача 1: дать любому избирателю возможность зашифровать свой голос, при этом позволив доверенной стороне его расшифровать

Для решения этой задачи можно применить асимметричное шифрование. В отличие от симметричного шифрования, использующего один и тот же ключ для шифрования и расшифровки данных, асимметричное шифрование использует два разных ключа: один для шифрования, другой для расшифровки. Существует множество алгоритмов асимметричного шифрования, в том числе ElGamal, RSA, DSA и методы на основе эллиптических кривых. В этом руководстве мы воспользуемся методами эллиптических кривых, а именно обменом ключами Диффи-Хеллмана(ECDH)](https://www.upguard.com/blog/diffie-hellman)(ECDH)).

В ECDH и доверенная сторона, и каждый избиратель имеют собственные пары ключей (открытый и закрытый ключ). Выбор эллиптической кривой, например кривой Baby Jubjub, используемой здесь благодаря её пригодности для доказательств с нулевым разглашением, влияет на безопасность и эффективность криптографических операций. Кривая Baby Jubjub определяется уравнением y^2 = x^3 + 168698 x 2 + x, а пары ключей генерируются как определённые пары точек на этой кривой:

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,
  };
};
Копировать

В функции generateKeyPair я сначала создаю криптографически стойкий случайный закрытый ключ, а затем вычисляю соответствующий открытый ключ, выполняя определённую математическую операцию (скалярное умножение) над заранее заданной точкой на кривой, которую часто называют базовой точкой или точкой-генератором. В коде эта точка называется Base8. Результатом данного умножения является другая точка на кривой, обозначенная координатами X и Y. Затем точка преобразуется в тип bigint для удобного использования с функцией packPoint.

Голосование с зашифрованным выбором

Избиратель создаёт шифр, комбинируя свой закрытый ключ с открытым ключом доверенной стороны. Этот шифр используется для шифрования голоса. Смарт-контракт затем сохраняет зашифрованный голос вместе с открытым ключом избирателя.

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;
};
Копировать

Расшифровка голосов и подсчёт результатов

Доверенная сторона может создать шифр, используя свой закрытый ключ и открытый ключ каждого избирателя, чтобы расшифровать данные.

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;
};
Копировать

В функции generateKeyPair мы отформатировали открытый ключ для удобного использования с функцией packPoint. В приведённом выше коде с помощью unpackPoint мы преобразуем его обратно в исходную форму, поскольку нам необходимо создать общий секрет путём комбинирования закрытого ключа и открытого ключа избирателя. Этот общий секрет используется для расшифровки голоса.

После расшифровки каждого голоса подсчёт результатов не составляет труда! Достаточно пройтись по массиву голосов и накапливать голоса для каждого варианта выбора.

Задача 2: обеспечить надёжность доверенной стороны и её способность предоставить ZK-доказательство вычислений. Но что именно мы доказываем?

Если вы не знакомы с ZK-доказательствами, это достаточно сложная, но увлекательная технология. ZK-доказательства — это метод, при котором одна сторона может доказать другой, что знает некое значение, не раскрывая никакой информации о самом значении. По сути, это способ подтвердить истинность чего-либо, не раскрывая деталей. При этом любой желающий может проверить корректность такого доказательства.

В нашем приложении в идеале доверенная сторона должна доказать, что она может взять зашифрованные голоса, расшифровать их и подсчитать количество голосов, полученных каждым вариантом. В данном случае «секретным» ключом является закрытый ключ доверенной стороны. Для простоты мы лишь доказываем владение закрытым ключом, соответствующим открытому ключу, зафиксированному в смарт-контракте. Как упоминалось ранее, голоса используют этот ключ для генерации шифра при шифровании голоса.

Для этой цели мы воспользуемся ZoKrates — набором инструментов для zkSNARK в Ethereum. Он помогает использовать верифицируемые вычисления в вашем dapp: от описания программы на языке высокого уровня до генерации доказательств вычислений и их верификации в Solidity.

Вот простая ZK-программа, которую я использую:

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;
}
Копировать

Как видно из списка аргументов, pk является публичным аргументом, а sk — приватным. context также является публичным. Только доказывающая сторона — доверенная сторона — знает sk, тогда как pk известен всем, поскольку содержится в смарт-контракте. context — это параметры, связанные с выбранной нами кривой для генерации пары ключей.

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)
};
Копировать

В приведённом фрагменте я использую ZoKrates JS SDK для генерации доказательства. Процесс аналогичен описанному в документации ZoKrates. Наша цель — использовать программу ZoKrates для генерации доказательства, поэтому мы передаём три аргумента функции computeWitness: pk (публичный аргумент), sk (приватный аргумент) и context. Они используются для создания свидетеля (witness), который затем применяется для генерации доказательства.

Возможно, вас интересует эта часть аргумента:

"8",
    "168700",
    "168696",
    "168698",
    "1",
    "0",
    "1",
  "16540640123574156134436876038791482806971768689494387082833631921987005038935",
    "20819045374670962167435360035096875258406992893633759881276124905556507972311"
Копировать

context предоставляет данные, связанные с конкретной кривой Baby JubJub, которую мы использовали на протяжении всего приложения, поскольку существует множество вариаций кривых. Такие файлы, как ключ верификации, ключ доказательства и программа ZoKrates, хранятся в отдельном каталоге. Для их получения я использую следующую функцию:

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,
  };
};
Копировать

Приведённый ниже фрагмент взят из контракта, в котором контракт принимает результаты, вычисленные доверенной стороной. Контракт принимает результат только в случае успешной верификации. Сторонние наблюдатели могут проверить это, передав pk (который является публичным и содержится в контракте) и доказательство в функцию верификатора:

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

     // accept results
Копировать

Что дальше?

В этой статье мы разработали Privote — систему приватного голосования на основе ZK, которая сохраняет конфиденциальность выбора избирателей до окончания периода голосования. Мы использовали методы эллиптических кривых для шифрования голосов и продемонстрировали, как безопасно расшифровывать и подсчитывать их. Мы также реализовали доказательства с нулевым разглашением с помощью ZoKrates для верификации целостности процесса голосования без раскрытия приватной информации.

Это руководство даёт базовое понимание создания безопасных и приватных систем голосования в блокчейне. Во второй части мы создадим фронтенд и покажем, как пользователи могут подключаться и голосовать с помощью MetaMask SDK.

Переведено ИИ. Может содержать ошибки. Пожалуйста, всегда проверяйте информацию.

Оцените перевод