登録有権者として特定の問題や候補者に投票することを想像してください。外部の第三者があなたに投票を売るよう求めてきます。つまり、何らかのインセンティブと引き換えに、あなた自身の選択ではなく、その第三者の選択に投票するよう求めてくるのです。その要求に応じた場合、投票の証明が必要となります。多くの場合、ブロックチェーンのトランザクションレシートがその証明として使われます。投票を売ることに抵抗がなければ、これは魅力的な機会に見えるかもしれませんが、投票プロセス全体の完全性を損なうことになります。
では、有権者が自分の好みの候補者や問題に投票しながら、その選択を誰にも見られないように隠すにはどうすればよいでしょうか?その答えは一言で表せます:暗号化です。
このチュートリアルでは、ブロックチェーン上で暗号化を活用するシンプルな投票アプリケーション「Privote」の開発方法を探っていきます。投票期間が終了すると、票が復号化・集計され、有権者の匿名性を維持しながら結果がブロックチェーンに公開されます。結果はゼロ知識(ZK)証明によって検証されます。完璧ではありませんが、この例は基本的な概念を示しています。
ブロックチェーン投票の詳細については、Vitalik Buterinのブログ記事をご参照ください。
本題に入る前に、Privote dappの主要コンポーネントを整理しておきましょう:
スマートコントラクト:複数の投票(バロット)と、タイトル、利用可能な選択肢、暗号化データなどの関連データを保存するシステムです。
信頼できる当事者(Trusted Party):票を復号化し、結果を計算してブロックチェーンに公開するオフチェーンサーバーです。このサーバーが「Trusted Party(信頼できる当事者)」と呼ばれるのには理由があります。計算の誤りや、メッセージの検閲によって、誤った結果を公開することができないからです。
フロントエンド:ユーザーがウォレットを接続して投票を送信するプラットフォームです。「管理者」が投票数を確認し、手動で結果を公開するための専用ページも用意されます。
Privoteを構築するにあたり、2つの課題に取り組む必要があります:
有権者が投票を暗号化できるようにしながら、Trusted Partyが復号化できるようにするには?
Trusted Partyの信頼性を確保し、計算のZK証明を提供できるようにするには?そして、具体的に何を証明するのか?
課題1:有権者が投票を暗号化できるようにしながら、Trusted Partyが復号化できるようにする
この課題を解決するために、非対称暗号化を採用できます。同じ鍵でデータの暗号化と復号化を行う対称暗号化とは異なり、非対称暗号化では2つの異なる鍵を使用します。1つは暗号化用、もう1つは復号化用です。非対称暗号化アルゴリズムには、ElGamal、RSA、DSA、楕円曲線技術など複数の種類があります。このチュートリアルでは楕円曲線技術、具体的にはDiffie-Hellman鍵交換(ECDH)](https://www.upguard.com/blog/diffie-hellman)(ECDH))を使用します。
ECDHでは、Trusted Partyと各有権者がそれぞれ独自の鍵ペア(公開鍵と秘密鍵)を持ちます。ゼロ知識証明への適合性からここで使用する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形式にフォーマットされます。
復号化された選択肢による投票
有権者は自分の秘密鍵とTrusted Partyの公開鍵を組み合わせて暗号(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;
};
投票の復号化と結果の計算
Trusted Partyは自分の秘密鍵と各有権者の公開鍵を使って暗号を生成し、データを復号化できます。
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を使って元の形式に戻しています。これは、秘密鍵と有権者の公開鍵を組み合わせて共有シークレットを生成する必要があるためです。この共有シークレットは投票の復号化に使用されます。
各投票を復号化した後は、結果の計算は簡単です!votesの配列をループして、各選択肢の票を集計するだけです。
課題2:Trusted Partyの信頼性を確保し、計算のZK証明を提供できるようにする。では、具体的に何を証明するのか?
ZK証明に馴染みがない方のために説明すると、ZK証明は複雑ながらも非常に興味深い技術です。ZK証明とは、ある当事者が値そのものに関する情報を一切明かすことなく、その値を知っていることを別の当事者に証明できる手法です。本質的には、詳細を共有せずに何かの真実性を検証する方法です。さらに、誰でもその証明が正しいかどうかを検証できます。
このアプリケーションでは、理想的にはTrusted Partyが、暗号化された投票を受け取り、復号化し、各選択肢が獲得した票数を計算できることを証明する必要があります。この場合、「秘密」鍵はTrusted Partyの秘密鍵です。簡略化のため、ここではスマートコントラクトにコミットされた公開鍵と一致する秘密鍵の所有権を証明するだけにとどめます。前述のとおり、投票はその鍵を使って暗号を生成し、投票を暗号化します。
この目的のために、EthereumのzkSNARKsツールボックスであるZoKratesを使用します。ZoKratesは、高水準言語でのプログラム仕様の記述から、計算証明の生成、Solidityでのそれらの証明の検証まで、dappで検証可能な計算を活用するのに役立ちます。
以下が私が使用するシンプルな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も公開引数です。証明者(Trusted Party)のみが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関数に3つの引数を渡しています:pk(公開引数)、sk(秘密引数)、そしてcontextです。これらはwitnessの生成に使用され、その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,
};
};
以下のスニペットはコントラクトの一部で、Trusted Partyが計算した結果をコントラクトが受け入れる処理を示しています。検証が成功した場合にのみ、コントラクトは結果を受け入れます。外部の人は、pk(公開情報であり、コントラクト内に存在する)と証明をverifier関数に渡すことで、これを検証できます:
if (!isVerified) {
revert("Invalid proof");
}
次のステップ
このブログでは、投票期間が終了するまで有権者の選択を秘密に保つZKベースのプライベート投票システム「Privote」を開発しました。楕円曲線技術を使用して投票を暗号化し、安全に復号化・集計する方法を示しました。また、プライベートな情報を明かすことなく投票プロセスの完全性を検証するために、ZoKratesを使用したゼロ知識証明を実装しました。
このチュートリアルは、ブロックチェーン上で安全かつプライベートな投票システムを構築するための基本的な理解を提供します。第2部では、フロントエンドを構築し、ユーザーがMetaMask SDKを使用して接続し、投票を行う方法を紹介します。