想像您是一位已登記的選民,正在針對特定議題或候選人進行投票。某個外部人士想要購買您的選票,換句話說,他們希望您以換取某些利益為條件,投票給他們屬意的選項,而非您自己的選擇。如果您屈服於他們的要求,他們將需要您的投票證明——通常是透過區塊鏈交易收據。雖然對於有意出售選票的人而言,這看似是個絕佳機會,但這將嚴重損害整個投票流程的公正性。
我們要如何讓選民能夠自由投票給自己屬意的候選人或議題,同時隱藏其選擇,使任何人都無法得知?答案就在一個詞:加密(Encryption)。
在本教學中,我們將探討如何開發一個簡單的投票應用程式——Privote——它在區塊鏈上運用加密技術。當時機到來時,選票將被解密並計票,結果在維護選民匿名性的前提下發布回區塊鏈。結果透過零知識(ZK)證明進行驗證。雖然並非完美,但此範例清楚闡明了其背後的核心概念。
如需深入了解區塊鏈投票,請參閱 Vitalik Buterin 的部落格文章。
在繼續之前,以下是 Privote dapp 主要組成部分的說明:
智能合約:此系統儲存多筆投票(選票)及相關資料,例如標題、可選選項和加密資料。
受信任方:一個鏈下伺服器,負責解密選票、計算結果並將其發布至區塊鏈。此伺服器被稱為「受信任方」是有原因的:它無法發布不正確的結果,無論是因計算錯誤或審查訊息所致。
前端:一個供使用者連接錢包並提交選票的平台。此外,還會有一個獨立頁面供「管理員」查看已投票數並手動公布結果。
為了成功建構 Privote,我們需要解決兩個挑戰:
如何讓任何選民都能加密其選票,同時允許受信任方進行解密?
如何確保受信任方是可靠的,並能提供計算過程的 ZK 證明——但我們究竟在證明什麼?
挑戰一:讓任何選民都能加密其選票,同時允許受信任方進行解密
為了解決這個問題,我們可以採用非對稱加密。與使用相同金鑰進行加密和解密的對稱加密不同,非對稱加密使用兩把不同的金鑰,一把用於加密,另一把用於解密。目前存在多種非對稱加密演算法,包括 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,以便於使用。
以加密選擇進行投票
選民透過將其私鑰與受信任方的公鑰結合來建立密文(cipher)。此密文用於加密選票。智能合約隨後將加密後的選票與選民的公鑰一併儲存。
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 將其轉換回原始形式,因為我們需要透過結合私鑰和選民的公鑰來建立共享密鑰(shared secret)。此共享密鑰用於解密選票。
解密每張選票後,計算結果便輕而易舉!只需遍歷選票陣列,並累計特定選項的票數即可。
挑戰二:確保受信任方是可靠的,並能提供計算過程的 ZK 證明。但我們究竟在證明什麼?
如果您對 ZK 證明不熟悉,它是一項相當複雜卻令人著迷的技術。ZK 證明是一種方法,讓一方能夠向另一方證明自己知道某個值,而無需透露關於該值本身的任何資訊。本質上,這是一種在不分享細節的情況下驗證某事真實性的方式。此外,任何人都可以驗證該證明是否正確。
對於我們的應用程式,理想情況下,受信任方應證明他們能夠取得加密選票、解密它們,並計算每個選項獲得的票數。在這種情況下,「秘密」金鑰就是受信任方的私鑰。為了簡化起見,我們僅證明對私鑰的所有權,且該私鑰與智能合約中提交的公鑰相匹配。如前所述,選票使用該金鑰生成密文以加密選票。
為此,我們將使用 ZoKrates,這是一個用於以太坊 zkSNARKs 的工具箱。它幫助您在 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 連接並投票。