등록된 유권자가 특정 안건이나 후보에 투표한다고 상상해 보세요. 외부 세력이 당신의 표를 사려 합니다. 즉, 어떤 인센티브를 대가로 자신들이 원하는 선택에 투표하도록 요구하는 것입니다. 그들의 요구에 응한다면, 투표 증거가 필요하게 되는데—이는 종종 블록체인 트랜잭션 영수증을 통해 이루어집니다. 표를 팔고 싶은 마음이 있다면 매력적인 기회처럼 보일 수 있지만, 이는 전체 투표 과정의 무결성을 훼손합니다.
유권자가 원하는 후보나 안건에 투표하면서도 그 선택을 외부에 노출되지 않도록 숨길 수 있는 방법은 무엇일까요? 답은 단 한 단어에 있습니다: 암호화(Encryption).
이 튜토리얼에서는 블록체인에서 암호화를 활용하는 간단한 투표 애플리케이션인 Privote를 개발하는 방법을 살펴봅니다. 투표 기간이 종료되면 투표 내용이 복호화되어 집계되고, 유권자의 익명성을 유지하면서 결과가 블록체인에 다시 게시됩니다. 결과는 영지식(ZK) 증명을 통해 검증됩니다. 완벽하지는 않지만, 이 예시는 기본 개념을 잘 보여줍니다.
블록체인 투표에 대한 심층적인 이해를 위해 Vitalik Buterin의 블로그 포스트를 참고하시기 바랍니다.
본격적으로 시작하기 전에, Privote dapp의 주요 구성 요소를 살펴보겠습니다:
스마트 컨트랙트: 여러 투표(ballot)와 제목, 선택 가능한 항목, 암호화된 데이터 등 관련 정보를 저장하는 시스템입니다.
신뢰 당사자(Trusted Party): 투표를 복호화하고 결과를 계산하여 블록체인에 게시하는 오프체인 서버입니다. 이 서버를 "신뢰 당사자"라고 부르는 데는 이유가 있습니다: 잘못된 계산이나 메시지 검열을 통해 부정확한 결과를 게시할 수 없습니다.
프론트엔드: 사용자가 지갑을 연결하고 투표를 제출하는 플랫폼입니다. 또한 "관리자"가 투표 수를 확인하고 수동으로 결과를 공개할 수 있는 별도의 페이지도 제공됩니다.
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 형식으로 변환됩니다.
복호화된 선택으로 투표하기
유권자는 자신의 개인 키와 신뢰 당사자의 공개 키를 결합하여 암호(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)을 생성해야 하기 때문에 원래 형태로 다시 변환합니다. 이 공유 비밀은 투표를 복호화하는 데 사용됩니다.
각 투표를 복호화한 후 결과를 계산하는 것은 간단합니다! 투표 배열을 순회하며 각 선택에 대한 투표를 누적하면 됩니다.
과제 2: 신뢰 당사자가 신뢰할 수 있으며 계산에 대한 ZK 증명을 제공할 수 있도록 보장하기. 그런데 정확히 무엇을 증명하는가?
ZK 증명에 익숙하지 않다면, 이는 상당히 복잡하지만 매우 흥미로운 기술입니다. ZK 증명은 한 당사자가 값 자체에 대한 어떠한 정보도 공개하지 않고 특정 값을 알고 있다는 것을 다른 당사자에게 증명할 수 있는 방법입니다. 본질적으로, 세부 정보를 공유하지 않고도 어떤 것의 진실성을 검증하는 방법입니다. 또한 누구든지 해당 증명이 올바른지 검증할 수 있습니다.
우리 애플리케이션에서 이상적으로는, 신뢰 당사자가 암호화된 투표를 가져와 복호화하고 각 선택이 받은 투표 수를 계산할 수 있다는 것을 증명해야 합니다. 이 경우 "비밀" 키는 신뢰 당사자의 개인 키입니다. 단순화를 위해, 스마트 컨트랙트에 커밋된 공개 키와 일치하는 개인 키의 소유권만을 증명합니다. 앞서 언급했듯이, 투표는 해당 키를 사용하여 암호를 생성하고 투표를 암호화합니다.
이를 위해 이더리움의 zkSNARK를 위한 툴박스인 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도 공개입니다. 증명자인 신뢰 당사자만 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를 생성하는 데 사용되며, 이 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");
}
다음 단계는?
이 블로그에서는 투표 기간이 종료될 때까지 유권자의 선택을 기밀로 유지하는 ZK 기반 비공개 투표 시스템인 Privote를 개발했습니다. 타원 곡선 기법을 사용하여 투표를 암호화하고, 안전하게 복호화하고 집계하는 방법을 시연했습니다. 또한 ZoKrates를 사용하여 영지식 증명을 구현함으로써 개인 정보를 공개하지 않고 투표 과정의 무결성을 검증했습니다.
이 튜토리얼은 블록체인에서 안전하고 비공개적인 투표 시스템을 만드는 방법에 대한 기본적인 이해를 제공합니다. 2부에서는 프론트엔드를 구축하고 사용자가 MetaMask SDK를 사용하여 연결하고 투표를 제출하는 방법을 보여드리겠습니다.