iT邦幫忙

2025 iThome 鐵人賽

DAY 24
1

前幾天我們去了動物園,那天太陽很大,曬得所有動物都受不了,它們都設法找一個陰影躲起來。我有一種說不清楚模糊的感覺,我也好希望跟這些動物一樣,有一些陰影可以躲起來 ... 我沒有水缸,沒有暗處,只有陽光,24小時從不間斷,明亮溫暖,陽光普照。
-- 電影《陽光普照》

特務 K 發現,儘管以太坊系統有眾多節點、有鉅額押金保護、驗算服務也有各種充滿創意的規模化專案,有那麼一小小小個點,讓他覺得不太對勁。

似乎 儀表板 打開一看,所有帳戶的餘額,往來的帳戶,交易紀錄清清楚楚。更有甚者,大家還會把帳戶綁個屬於自己的 .eth 域名。域名的確是幫助人們不用記憶冗長的十六進位地址,也避免各種作業失誤和駭客的誤導攻擊,卻也讓人們容易指認出哪些帳戶屬於誰。

特務 K 似乎碰觸到一個他不太熟悉、不太在乎,又常常在他工作上常需冒犯他人的議題:隱私

「的確是還有些更誇張的儀表板,彙整出某些知名人士,他們都持有什麼代幣,以及做了什麼交易」小雨說。

隱私的重要性

的確人們可能會想要錢很多的人,盡量可以揭露他們金錢的影響力和流向。目前最想要出力或投資隱私的也是這些不堪其擾的有錢人。

另外一種很想要隱私的人:壞人。 北韓駭客 已經透過攻擊各種交易所與合約漏洞,取得大量的資金,資助其彈道飛彈的設計,讓世界籠罩在核彈威脅之下。他們有隱藏其贓款的需求。

但隱私對一般使用者來說也極其重要。如果使用者在使用去中心化應用時,所有小小的金流流向都對所有網路中的陌生人一清二楚,那區塊鏈不僅沒有修正目前網路公司獨佔人們資料的霸權,反而成為下一個更可怕的數位監控怪物。

在區塊鏈的圈子人們有一句話,使用區塊鏈就像把你的銀行帳戶與交易放到推特上公開一樣。

有個理智健全的系統,應該讓公司需要能夠付薪水,薪水不被陌生人知道。去超商買杯咖啡,不會全世界都發現。

掙脫不掉的追蹤之鏈

人們大多是從交易所,用現金換取第一筆幣的。但要在交易所換到幣,代表交易所已經透過 KYC 流程,取得人們的個資了。這套流程固有傳統金融打擊金融犯罪的目的,但同時交易所也會知道一個使用者,從交易所出去的代幣,最後都到了哪裡。這是除了網路上陌生人之外,一般使用者該有的隱私顧慮。

單純試圖把帳號的餘額轉到新的帳號是行不通的。轉到新的餘額,代表我們需要發一筆交易,這筆交易的資訊會把新帳戶與舊帳戶關聯起來,這樣分析者就知道新舊帳戶是一起的。

人們也試著把金額拆小,做好幾筆混淆交易,試圖混淆分析交易的人。但在幣流分析專家與工具如 chainalysis 的分析之下,混淆幾乎沒有任何意義。

混幣器 Mixer

混幣器目前是已知有效的擺脫帳戶關聯的做法。這邊關鍵的技術也是零知識證明。

這個思路是這樣: N 個人把幣打到某個合約,同樣的 N 個人再從合約領幣出來。只要沒辦法關聯存款者與提款者,要猜中正確的帳戶關聯就是 1/N 的機率。

「我們必須要談到一個最知名,最具爭議的專案」小雨說

2019 年開始的龍捲風現金(Tornado Cash),是以太坊上面最知名的專案。在這之前雖然有 Zcash 等應用零知識證明,改良比特幣,所做出來的區塊鏈專案。但龍捲風現金影響到的資金規模較大,引起的衝擊與對隱私的討論,以及後續效應,更值得直接討論。

「最主要,龍捲風現金的程式碼很短,還能順便觀摩一個夠有影響力的零知識證明專案怎麼運作」小雨說。「我們今天先講技術,後講爭議。」

龍捲風現金

龍捲風現金的設計是這樣:合約只有存款和提款兩個函式。

合約提供各種 匿名集(Anonymous Set),也就是同一種幣與同一種金額大小的存款者集合。合約支援以太幣和 ERC20 代幣。以太幣有一顆、十顆、百顆,三種餘額的匿名集可以選擇。強制用同一種餘額才不會因為金額不一樣而暴露存提款者之間的關係。

使用者把一筆金額,例如一顆以太幣,存入龍捲風現金合約。過了一段時間之後,也許幾個月,再從合約提款。

這裡很重要的是不能太快提款,否則會太容易因為時間關係被辨認出來。

這邊有個重要的點: 隱私不是一種功能,而是整個流程 。龍捲風只保障鏈上的隱私,但使用者需要在所有流程中小心不要外洩資訊。例如:舊帳戶和新帳戶未來又有其他的互動,這樣混幣器就是白混了。

維安劇場(Security theater) 是一個資訊安全的詞,代表某個維護安全的步驟徒具戲劇效果,但失去其資安的實際作用」小雨說。

提款

提款的時候,需要提交一個零知識證明,說明自己是合格的提款者。提款的證明程式,證明下面兩件事:

  • 使用者曾經存款。這確保提款者不是一個之前沒存過款,來亂的路人。
  • 使用者還沒提過款。確保提款者沒有重複提款,存一筆錢但領兩筆錢。

提款的時候,使用者也不會自己送交易去和合約互動。要透過第三方的中繼者(Relayer)去和合約互動。因為和合約互動需要用以太幣付燃氣手續費,而使用者用自己手上的帳戶去提款,有暴露存款者身份顧慮。

使用者可以把提款中的一小部分給中繼者當手續費。零知識證明可以順便當零知識數位簽章用,證明使用者願意給某個地址多少手續費。

密碼學原理

想像我們有兩種雜湊函式,hash1 和 hash2 。使用者有個秘密的隨機值,這個隨機值必須夠大,例如: 128 位元,讓電腦無法暴力搜索,從雜湊值反推。

  • 存款:留下 hash1(random)
  • 提款:留下 hash2(random)

注意外人無法從雜湊值 hash1(random)hash2(random) 得知其關聯。

因此使用者在提款時:

  1. 合約先檢查 hash2(random) 不曾出現過,確認該使用者尚未提款。
  2. 零知識證明迴路
    1. 以私密參數接收 hash1(random) ,以公開參數接收 hash2(random)
    2. 驗證 hash1(random)hash2(random) 背後的隨機值是相同的。這樣驗證存提款是同一個人,卻又不揭露存款者的資訊。
  3. 最後合約把 hash2(random) 記錄在鏈上。這樣重複提款時,在步驟一就會失敗。

hash2(random) 像是在存款名單上,把提款過的人的名字劃掉,但劃掉了誰只有提款者知曉。

程式碼

在 Github 上的 龍捲風現金 程式碼倉庫,曾經因為官司,被微軟預防性下架。在電子前哨基金會 EFF 的訴訟支援之下,還原程式碼倉庫,供人們研究其程式碼。

合約

合約的核心是 Tornado.sol ,裡面就兩個函式:存款和提款。

Tornado 用的 hash1 與 hash2 如下:

  • 存款 hash1 Hash(nullifier + secret)
  • 提款 hash2 Hash(nullifier)

其中 Hash(nullifier + secret) 可以看作為 Hashsecret (一般人們稱作「鹽」)組成的新雜湊函式,對參數 nullifier 作用。

註銷符(nullifier) 是一個隱私類零知識證明常見的詞,本質上就是個隨機數。

存款

存款時,使用者會需要提交 hash1 。合約背後會建立出一棵雜湊樹,名曰存款樹。_insert 會把存款束縛,放入存款樹陣列中。

/**
  @dev Deposit funds into the contract. The caller must send (for ETH) or approve (for ERC20) value equal to or `denomination` of this instance.
  @param _commitment the note commitment, which is PedersenHash(nullifier + secret)
*/
function deposit(bytes32 _commitment) external payable nonReentrant {
  require(!commitments[_commitment], "The commitment has been submitted");

  uint32 insertedIndex = _insert(_commitment);
  commitments[_commitment] = true;
  _processDeposit();

  emit Deposit(_commitment, insertedIndex, block.timestamp);
}

其他細節:

  • _processDeposit 是抽象出來的函式,原生以太幣與 ERC20 有不同的轉帳函式,會實作在其繼承的合約中。
  • require 裡面有防呆,避免使用者提交兩次一樣的存款。
  • emit Deposit 最後會釋放出一個日誌,這邊關鍵的是 insertedIndex 參數,日後要提款時,要製造出雜湊樹成員證明,必須知道自己的存款被放到陣列的第幾個位置。這必須是存款被區塊鏈把包之後才會知道的事,因為有可能很多人同時在存款,存款順序是區塊決定的。

提款

使用者的網頁會去取得所有的存款紀錄,並重構出存款樹。越新的存款樹,代表裡面裝載更多存款,也代表混幣的效果越好。

提款時要提交:零知識證明 _proof 、存款樹的樹根 _root 、提款的 hash2 、提款金額的收受人 _recipient、中繼人小費收受者 _relayer、小費金額 _fee 本身。_refund 是 ERC20 合約的小細節,我們先不理會。

合約主要檢驗:

  • hash2 是否已經出現過,避免重複提款。
  • 零知識證明是否驗證通過(背後是橢圓曲線的密碼學運算)
  • 最後把 hash2 記錄在鏈上。
/**
  @dev Withdraw a deposit from the contract. `proof` is a zkSNARK proof data, and input is an array of circuit public inputs
  `input` array consists of:
    - merkle root of all deposits in the contract
    - hash of unique deposit nullifier to prevent double spends
    - the recipient of funds
    - optional fee that goes to the transaction sender (usually a relay)
*/
function withdraw(
  bytes calldata _proof,
  bytes32 _root,
  bytes32 _nullifierHash,
  address payable _recipient,
  address payable _relayer,
  uint256 _fee,
  uint256 _refund
) external payable nonReentrant {
  require(_fee <= denomination, "Fee exceeds transfer value");
  require(!nullifierHashes[_nullifierHash], "The note has been already spent");
  require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
  require(
    verifier.verifyProof(
      _proof,
      [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
    ),
    "Invalid withdraw proof"
  );

  nullifierHashes[_nullifierHash] = true;
  _processWithdraw(_recipient, _relayer, _fee, _refund);
  emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
}

其他細節:

  • 存款樹的樹根其實合約是保留一段近期有效的樹根。這是因為使用者在產零知識證明時,可能其他人仍然在存款。鏈上的存款樹樹根便會不斷更新。因此設計讓使用者去提交樹根,只要是時間夠近的存款樹根即可。

提款證明使用的迴路

除了必要的雜湊函式、數值位元轉換、雜湊樹之外,龍捲風現金加上註解居然不到 100 行程式碼。

重點:

  • 使用者會輸入公開輸入 hash2 以及存款樹根,這兩個值必須配合合約上面的驗證才能避免使用者去欺騙迴路。
  • CommitmentHasher 計算必要的雜湊值: hash1 和 hash2 。
    • hash2 由 hasher.nullifierHash === nullifierHash; 這行檢查與使用者輸入值相同。
    • hash1 則以 hasher.commitment 的方式,做雜湊樹成員檢查,確認有包含在存款樹根為 root 的樹中。
  • 使用者會輸入私密值 secret 與 nullifier ,以及做成員證明所需要的雜湊樹分枝。
include "../node_modules/circomlib/circuits/bitify.circom";
include "../node_modules/circomlib/circuits/pedersen.circom";
include "merkleTree.circom";

// computes Pedersen(nullifier + secret)
template CommitmentHasher() {
    signal input nullifier;
    signal input secret;
    signal output commitment;
    signal output nullifierHash;

    component commitmentHasher = Pedersen(496);
    component nullifierHasher = Pedersen(248);
    component nullifierBits = Num2Bits(248);
    component secretBits = Num2Bits(248);
    nullifierBits.in <== nullifier;
    secretBits.in <== secret;
    for (var i = 0; i < 248; i++) {
        nullifierHasher.in[i] <== nullifierBits.out[i];
        commitmentHasher.in[i] <== nullifierBits.out[i];
        commitmentHasher.in[i + 248] <== secretBits.out[i];
    }

    commitment <== commitmentHasher.out[0];
    nullifierHash <== nullifierHasher.out[0];
}

// Verifies that commitment that corresponds to given secret and nullifier is included in the merkle tree of deposits
template Withdraw(levels) {
    signal input root;
    signal input nullifierHash;
    signal input recipient; // not taking part in any computations
    signal input relayer;  // not taking part in any computations
    signal input fee;      // not taking part in any computations
    signal input refund;   // not taking part in any computations
    signal private input nullifier;
    signal private input secret;
    signal private input pathElements[levels];
    signal private input pathIndices[levels];

    component hasher = CommitmentHasher();
    hasher.nullifier <== nullifier;
    hasher.secret <== secret;
    hasher.nullifierHash === nullifierHash;

    component tree = MerkleTreeChecker(levels);
    tree.leaf <== hasher.commitment;
    tree.root <== root;
    for (var i = 0; i < levels; i++) {
        tree.pathElements[i] <== pathElements[i];
        tree.pathIndices[i] <== pathIndices[i];
    }

    // Add hidden signals to make sure that tampering with recipient or fee will invalidate the snark proof
    // Most likely it is not required, but it's better to stay on the safe side and it only takes 2 constraints
    // Squares are used to prevent optimizer from removing those constraints
    signal recipientSquare;
    signal feeSquare;
    signal relayerSquare;
    signal refundSquare;
    recipientSquare <== recipient * recipient;
    feeSquare <== fee * fee;
    relayerSquare <== relayer * relayer;
    refundSquare <== refund * refund;
}

component main = Withdraw(20);

其他細節:

有趣的是 recipient fee relayer refund 這幾個參數。迴路最後做了一些沒有用途的運算。目的是為了讓這些變數留在迴路裡面,讓限制式發揮作用。這會卡住他們在合約中的值,相當於變相用零知識證明對這些值做數位簽章。

審計迴路

零知識證明迴路需要滿足兩種性質:

  • 正徒有路(Completeness):對所有正常的業務邏輯,證明方 都有能力 產出能通過驗算的證明。
  • 邪道無門(Soundness):違反正常業務邏輯的程式路徑,證明方都 沒辦法 產出能通過驗算的證明。

對於隱私類的應用,額外要求:

  • 零知(zero-knowledge):驗算過程不應該洩漏業務邏輯內該保密的資訊。

因此對應以上性質,零知識證明迴路特有的程式錯誤有三類:

  • 過度限制(Completeness Bug 或 Over-constrained)
    • 描述:多寫了某些限制式,使得某些正常業務邏輯無法執行。
    • 範例:加入一行 1 === 0 ,這樣什麼證明都產不出來。
  • 疏忽限制(Soundness Bug 或 Under-constrained)
    • 描述:少寫了某些限制式,使得壞人可以執行異常邏輯
    • 範例:移除龍捲風現金提款迴路的 hasher.nullifierHash === nullifierHash; 這行限制式。迴路便不會檢查,迴路內算出來的雜湊值與公開輸入的雜湊值。壞人可以在公開輸入放任意的雜湊值,供合約記錄。這樣壞人每次用不同的公開雜湊值,可以一次存款,多次提款,掏空合約的錢。
  • 暴露資訊
    • 描述:不小心暴露私密訊息了。
    • 範例:把龍捲風現金提款迴路的 nullifiersecret 變數設為公開輸入,這樣提款者的私密資訊曝光,任何人可以用這兩樣資訊推導出提款者身份。

總體來說,疏忽限制是最危險的,這讓壞人可以做壞事,而且因為零知識證明的性質,通常沒辦法知道系統已經有漏洞、漏洞正在被利用、抓出壞人是誰、或做災害還原。在隱私類應用中,暴露資訊是第二嚴重的,通常會造成使用者的一些傷害。過度限制則稍微無害一點,儘管是讓系統動彈不得,但通常傷害可知,也知道怎麼復原。


上一篇
不能獨自升級的電腦
下一篇
隱私要無限上綱或網開一面?
系列文
那個有好多好多節點的電腦調查報告25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言