zkVaxCard

February 08, 2023

ZKPlayground Project

zkVaxCard

Author: Fu-Chuan Chung @ swfLAB 感謝 @Paul review 此篇文章

目錄

  • 前言
  • 簡介
  • Semaphore 介紹
  • zkVaxCard 系統設計
  • 技術說明
  • 遇到的瓶頸
  • 結論

前言

前情提要:我們的原本主題想使用 SBT (Soul Bound Token) 、semaphore 及 Tornato Cash 背後之零知識證明技術,但後來因時間與能力問題更改成最後的專案。

ZKPlayground 是由以太坊基金會在背後推動的活動,內容包含講師教授零知識相關知識與應用,而本文會介紹關於 SWF Lab 參加成員 (Foodchain、yanlong、nooma、iver) 四人在此活動中完成的一項專案 - zkVaxCard

簡介

zkVaxCard 是一個可以幫助人們在「不揭露任何個人資訊如身分證字號、個人照片或健保卡卡號」之情況下「證明自己有接種過疫苗」之系統,以 React.js 為前端開發的框架、json-server 作為一個非常臨時的後端資料庫,並以 Semaphore protocol 作為其背後的零知識證明技術,同時參考了 semaphore 上的範例專案 - Bolierplate

本文將不深入 semaphore 中 circom 電路與其原理,只會講述如何使用 semaphore 套件與套件中用到的 javascript 語法等。

Semaphore

Semaphore 是在以太坊生態上開發的 ZKP 相關 project 之一,可以透過 ZKP 來完成身份的認證。

  • private: 🌚
  • public: 🌝

在 semaphore 中有幾個重要的元素:

  1. identity:一組身份,其中由三個部分組成: 🌚 nullifier、🌚 trapdoor 與 🌝 commitment。可以把它想像成在 Ethereum 上的一個私鑰 (nullifier & trapdoor) 與地址 (commitment) 的關係。
  2. Group:semaphore 利用 incremental binary Merkle Tree,來儲存在某一團體中的每一個身份 (意指每一個 Merkle Leaf 都是一個 identity) 。在鏈上,每一棵樹都會擁有自己的編號 (group id) 與樹高 (tree depth) 。
  3. ZKproof:擁有 identity 並且加入一組 group 後,便可以用 circuit 來計算出一段 ZKproof。透過傳送這段 proof 到鏈上的合約,便可以「不揭露自己的地址」但卻能「證明自己是存在於某個團體之中

Semaphore 已經用 circom 寫好他們的 circuit,詳見 fig. 1

fig. 1 Circuits in semaphore by Cedoor


舉例來說,semaphore 可以用來建立一個投票系統:

投票者可以透過 semaphore 取得一組 🌚 identity (nullifier & trapdoor) ,利用一串 secret 計算出一個 🌝 commitment,並將此 🌝 commitment 存入在鏈上的 Merkle Tree (Group) ,同時計算出一個新的 Merkle Root。接著,當投票的時候,投票者則需要透過 semaphore 產生出 ZKProof,隨後將投票資訊、groupId、Merkle Root、ZKproof 等資料提交到在鏈上的 verifyProof(),便可以驗證投票者的身份,並在鏈上公開的紀錄投票結果了。

Semaphore 也利用了一個變數 external nullifier 來避免 double signaling (例:同一組身份但是投兩次票) 的問題,可以把它想像成在 ethereum 交易中的 nonce。

Verication Flow

接著來說明 zkVaxCard 的系統流程,可參考 fig. 2

fig. 2 System design of zkVaxCard

整個系統我們分成兩個部分:

  • 注射的醫師或單位 (Doctor/ Government) :醫生在進行注射後需要將接種者加進對應的 group 中。
  • 接種者 (Vaxxer) :想要提出自己有接種過疫苗的人。

在我們的疫苗注射卡上面會有醫院證明、醫師蓋章等資訊來證實這張卡的真偽;另外也避免一般人也可以隨意 addMember() 的情況,因此需要由認證後醫師的 address 才能執行 addMember()

步驟 (參考 fig. 2 中的 Step 1 ~ 8) :

前提:

  • 醫師端需要設定一組白名單 (裡面會存放 Ethereum 上的 address) 。
  • 下面的故事發生在一個接種者打完疫苗後。
  1. 醫師登入自己的 address,透過白名單認證後便可以執行。
  2. 醫師會請接種者輸入一串只有自己知道的密碼 (帳號為身分證字號) ,系統會講這串資訊 parse 並進行 hash,得到 h,製造一組新的 Identity(h)。 (像是在銀行設定提款機密碼一樣)
  3. 醫師按下 addMember 的按鈕,此時由 semaphore 計算得到的 commitment 會送到已建立的合約中,存放在鏈上的對應的 Merkle Tree。
  4. 同時我們的 database 中也會儲存關於此 commitment 的資訊 (包含 groupId、nonce、commitment) 。
  5. 當接種者想要提出證明時,會輸入自己的帳號密碼,zkVaxCard 會透過儲存的資訊來檢查是否有這組 identity。
  6. 認證 identity 後,會透過一組存在系統中的私鑰來與鏈上合約進行交易 (此步的目的是為了不揭露接種者地址) ,傳送 groupId、Merkle Root、ZKproof 等資訊到 verify() 中進行驗證。
  7. 透過 semaphore 的 verifyProof(),可以進行此 identity 是否在 group 的驗證,若成功 (回傳 True) ,則顯示 Successfully Verified。
  8. 驗證者 (像是店家或政府機關) 則可以參考驗證成功的畫面進行驗證。

技術解析

這部分會依循上方的順序,解釋在其中做了什麼事。

password & identity

在使用者輸入密碼時,我們會用下面的方法將這串資訊轉換成 hash 值: in doctorModal.js

const beforeHash = addId.concat(password);
let seed = keccak256(beforeHash).toString("hex");
//...

並將其值直接在 constructor 送入 Identity 中,製作出一串屬於接種者自己的 identity。 in useVax()

import { Identity } from "@semaphore-protocol/identity";
//...
const identity = new Identity(seed);
const identityCommitment = identity.generateCommitment().toString();

// V3 的 commitment 則是直接存在 Identity object 中
// ex: const {nullifier, trapdoor, commitment} = new Identity(seed);
//...

這樣的做法有兩個主要目的:

  1. 使用者每次都可以使用自己記得住的密碼來做 verify。
  2. 使用者不需要記住兩串很長的字串 (nullifier 與 trapdoor 兩者都是 bytes31)

Add member into onchain merkle tree (group)

在合約部署到鏈上時,會先在 consturctor 中建立五個 group。

隨後醫師將接種者加入 group 時,會呼叫在鏈上合約中的 joinGroup()

function joinGroup(uint256 identityCommitment, bytes32 username, uint256 groupId) external {
    semaphore.addMember(groupId, identityCommitment);
    if (groupId == vacId[0]) emit NewVaccinater1(identityCommitment, username);
    else if (groupId == vacId[1]) emit NewVaccinater2(identityCommitment, username);
    else if (groupId == vacId[2]) emit NewVaccinater3(identityCommitment, username);
    else if (groupId == vacId[3]) emit NewVaccinater4(identityCommitment, username);
    else if (groupId == vacId[4]) emit NewVaccinater5(identityCommitment, username);
}

在鏈下傳送交易:

const transaction = await contract.joinGroup(
  identityCommitment,
  utils.formatBytes32String("seed"),
  groupId[doze - 1]
);
await transaction.wait();

database (json-server)

後端的部分我們做了一個很簡單的 json-server,就是直接利用 cli 來跑一個本地的 port。

$ yarn json-server db.json --port 4000
>
yarn run v1.22.19
$ /Users/foodchain/Documents/swf_lab/ZKP/zkVaxCard/frontend/node_modules/.bin/json-server db.json --port 4000

  \{^_^}/ hi!

  Loading db.json
  Done

  Resources
  http://localhost:4000/vac

  Home
  http://localhost:4000

  Type s + enter at any time to create a snapshot of the database

在 json file 中,每組資料會紀錄:

  • commitment:透過 seed 產生的 commitment。
  • nonce:作為 external 傳入的變數。
  • id:代表會存入鏈上的哪一個 groupId。

每次醫師加入一組 commitment 到鏈上時,也同時會將資訊更新在此處。


Verify

需要輸入 verify() 的參數有:

  1. message
  2. Merkle Root
  3. nullifierHash
  4. ZKProof

下面我們就用盡洪荒之力生出這幾個參數 (?) ,但其實其中最重要的就是 Merkle Root

這邊的方法由 @ChiHaoLu 提供 🙏: 首先要去 Goerli (你部署的鏈) 上的 semaphore 合約 (在 Goerli 為0x5259d32659F1806ccAfcE593ED5a89eBAb85262f) 。

透過 ABI 來取得 function selector,並取得其 Merkle Root:

const s = new Contract("0x5259d32659F1806ccAfcE593ED5a89eBAb85262f",
    [{
      "inputs": [
        {
          "internalType": "uint256",
          "name": "groupId",
          "type": "uint256"
        }
      ],
      "name": "getMerkleTreeRoot",
      "outputs": [
        {
          "internalType": "uint256",
          "name": "",
          "type": "uint256"
        }
      ],
      "stateMutability": "view",
      "type": "function"
    }], signer);

const merkleRoot = s.getMerkleTreeRoot(<groupId>); // 輸入你傳入的 groupId
//...

隨後便可以傳到鏈上進行驗證了:

//...
// verify on chain
const transaction = await contract.verify(
  message,
  root.toString(),
  nullifierHash,
  solidityProof
);

await transaction.wait();

// update external nullifier
await axios.put(`/vac/${data.data[0].id}`, {
  nonce: externalNullifier + 1,
});

但同時也需要更新在 json-server 中的 nonce。

技術瓶頸

這段會簡述一下我們在開發中遇到的一些問題,希望未來可以不要再撞同樣的牆。

Double Signaling:

在研究 semaphore 的範例專案時,我們發現無法透過相同的 identity 驗證兩次,這是因為防止 double signaling 的問題。在 semaphore 中是透過 external nullifier 來限制的,因此只要每次傳入 verify 時都傳入不同的 external nullifier 便可以完成「同一組 identity 重複的驗證」。

GroupId:

在建立 group 時,需要傳入 groupId。但常常傳入的 groupId 已經被別人用過了,因此需要找到一組別人沒使用過的 groupId。

Merkle Root:

原本我們參照 Bolierplate,其中使用 generateProof() 來產生 proof、merkleTreeRoot、nullifierHash 等。 in Boilerplate/apps/web-app/src/pages/proof.tsx

const { proof, merkleTreeRoot, nullifierHash } = await generateProof(
  _identity,
  group,
  env.GROUP_ID,
  feedbackBytes32
);

但實測後發現它回傳的 MerkleRoot 是錯的,因此使用上一段的方法。

結論

雖然在已經開放後的台灣,zkVaxCard 似乎有點過時,但是我認爲它也可以應用在一些其他的領域,像是財產證明、修課證書、能力證書、會員制度等。可以不洩漏使用者的隱私 (像是銀行帳號) ,提出具有公信力的證明。

另外我認為 password 更安全的做法是利用一組 EdDSA/EcDSA 產生的私鑰,將自己設定的密碼做簽章,並將此作為 seed,但是在開發上可能須考慮便利性問題 (如使用者需要記住的東西有多少) ,才能真正讓這些應用實施在現實生活中

References