想象一下,您是一名已登记的选民,正在就某一议题或候选人进行投票。某外部方希望您出售您的选票,也就是说,他们希望您以获取某些利益为条件,按照他们的意愿而非您自己的意愿进行投票。如果您屈服于他们的要求,他们将需要您投票的证明——通常通过区块链交易收据来实现。虽然对于有意出售选票的人来说,这似乎是一个绝佳机会,但这会破坏整个投票过程的公正性。
我们如何才能让选民为自己偏好的候选人或议题投票,同时隐藏他们的选择,使其他任何人都无法看到?答案只有一个词:加密。
在本教程中,我们将探索如何开发一个简单的投票应用程序——Privote——该应用程序利用区块链上的加密技术。到了特定时间,选票将被解密并统计,结果将在保持选民匿名性的同时发布回区块链。结果通过零知识(ZK)证明进行验证。尽管并不完美,但这个示例阐明了其背后的核心概念。
请参阅 Vitalik Buterin 的博客文章,以深入了解区块链投票。
在继续之前,以下是 Privote dapp 主要组件的概述:
智能合约:该系统存储多张选票(ballot)及相关数据,例如标题、可用选项和加密数据。
可信方:一个链下服务器,负责解密选票、计算结果并将其发布到区块链上。该服务器被称为"可信方"是有原因的:它无法发布不正确的结果,无论是通过错误计算还是审查消息。
前端:用户连接钱包并提交选票的平台。还将有一个单独的页面供"管理员"查看已投票数并手动发布结果。
为了成功构建 Privote,我们需要解决两个挑战:
如何让任意选民加密其选票,同时允许可信方对其进行解密?
如何确保可信方是可靠的,并能提供计算的 ZK 证明,但我们究竟在证明什么?
挑战 1:让任意选民加密其选票,同时允许可信方对其进行解密
为了解决这个问题,我们可以采用非对称加密。与使用同一密钥加密和解密数据的对称加密不同,非对称加密使用两个不同的密钥,一个用于加密,另一个用于解密。目前存在多种非对称加密算法,包括 ElGamal、RSA、DSA 以及椭圆曲线技术。在本教程中,我们将使用椭圆曲线技术,具体为 Diffie-Hellman 密钥交换(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";
export const generateKeyPair = () => {
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 坐标表示。随后,该点通过 packPoint 函数被格式化为 bigint,以便于使用。
使用加密选项进行投票
选民通过将其私钥与可信方的公钥相结合来创建密码。该密码用于加密选票。智能合约随后将加密后的选票和选民的公钥一起保存。
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
) => {
const unpackedKey = unpackPoint(trustedPartyKey);
if (!unpackedKey) {
throw new Error("Invalid key");
}
const sharedSecretPoint = mulPointEscalar(unpackedKey, privateKey);
let sharedSecret = sharedSecretPoint[0].toString(16);
while (sharedSecret.length < 64) {
sharedSecret = "0" + sharedSecret;
}
const cipher = crypto.createCipheriv(
"aes-256-ctr",
Buffer.from(sharedSecret, "hex"),
Buffer.alloc(16, 0)
);
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);
......
......
};
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);
while (sharedSecret.length < 64) {
sharedSecret = "0" + sharedSecret;
}
const decipher = crypto.createDecipheriv(
"aes-256-ctr",
Buffer.from(sharedSecret, "hex"),
Buffer.alloc(16, 0)
);
let decrypted = decipher.update(encryptedData, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
};
在 generateKeyPair 函数中,我们将公钥格式化以便与 packPoint 函数配合使用。在上述代码中,通过 unpackPoint,我们将其转换回原始形式,因为我们需要通过将私钥与选民的公钥相结合来创建共享密钥。该共享密钥用于解密选票。
解密每张选票后,计算结果就很简单了!只需遍历选票数组并累计每个选项的票数即可。
挑战 2:确保可信方是可靠的,并能提供计算的 ZK 证明。但我们究竟在证明什么?
如果您对 ZK 证明不熟悉,它是一项相当复杂却令人着迷的技术。ZK 证明是一种方法,通过它,一方可以向另一方证明自己知道某个值,而无需透露关于该值本身的任何信息。本质上,这是一种在不共享细节的情况下验证某事真实性的方式。此外,任何人都可以验证该证明是否正确。
对于我们的应用程序,理想情况下,可信方应证明他们能够获取加密选票、对其解密,并计算每个选项获得的票数。在这种情况下,"秘密"密钥就是可信方的私钥。为了简单起见,我们只是证明私钥的所有权与智能合约中提交的公钥相匹配。如前所述,选票使用该密钥生成密码来加密选票。
为此,我们将使用 ZoKrates,这是一个用于以太坊 zkSNARK 的工具箱。它帮助您在 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");
}
下一步是什么?
在本博客中,我们开发了 Privote,这是一个基于 ZK 的私密投票系统,在投票期结束之前保持选民选择的保密性。我们使用椭圆曲线技术对选票进行加密,并演示了如何安全地解密和统计选票。我们还使用 ZoKrates 实现了零知识证明,以在不泄露私密信息的情况下验证投票过程的完整性。
本教程提供了在区块链上创建安全私密投票系统的基本理解。在第二部分中,我们将构建前端,并向您展示用户如何使用 MetaMask SDK 连接并投票。