作者:laker & 極光 @知道創宇404區塊鏈安全研究團隊
日期:2021年3月8日

前言

區塊鏈從設計上并不是完全匿名的,充其量是偽匿名的。任何一個人或者組織可以輕而易舉的創建一個Hash賬戶并參與校驗,雖然這樣的賬戶不會與個人進行一一對應,但由于交易的公開性、任何人都可以在公共賬本上追蹤你的交易,并可能利用這些信息找出你的真實身份。

尋求匿名不僅僅是罪犯和有事要隱瞞的人的專利;匿名購買比特幣有很多理由。加密貨幣交易所持有的 know your customer (KYC)數據是黑客攻擊的主要目標,包括 Binance 和 Coinsquare 在內的多家交易所近年來都遭到黑客攻擊。

什么是零知識證明

零知識證明(Zero—Knowledge Proof) 是由S.Goldwasser、S.Micali及C.Rackoff在20世紀80年代初提出的。它早于區塊鏈誕生,但由于區塊鏈,它被大家所熟知。它指的是證明者能夠在不向驗證者提供任何有用的信息的情況下,使驗證者相信某個論斷是正確的

零知識證明可以分為交互式非交互式兩種。

  • 交互式

零知識證明協議的基礎是交互式的。它要求驗證者不斷對證明者所擁有的“知識”進行一系列提問。證明者通過回答一系列問題,讓驗證者相信證明者的確知道這些"知識"。然而,這種簡單的方法并不能使人相信證明者和驗證者都是真實的,兩者可以提前串通,以便證明者可以在不知道答案的情況下依然通過驗證。

  • 非交互式

非交互式零知識證明不需要交互過程,避免了串通的可能性,但是可能需要額外的機器和程序來確定實驗的順序。

通俗的來講,就是既證明了自己想證明的事情,同時透露給驗證者的信息為"零"。

比如:用戶在系統注冊時,系統不會保存用戶的密碼明文,而是保存了密碼的哈希值;用戶在登錄系統時,只需要輸入注冊時的密碼,系統會根據用戶輸入密碼產生的哈希值與系統數據庫保存的哈希值進行比對。如果一致,則系統認為當前登錄用戶知道該賬號的密碼

這樣,用戶不需要告訴網站密碼,就能證明自己的身份。這其實就是一種零知識證明。

混幣服務tornado.cash

Tornado.cash 可幫助你收回隱私,以隱藏發送地址的方式將 Ether 發送到任何地址。通過零知識證明實現。你可以使用此應用將 ETH 存入非托管智能合約,然后輕松生成憑據來證明你已經執行了存款,但未透露原始地址。而后,取款時應用會將此證明發送給服務商,服務商會將其提交給智能合約,然后智能合約將 ETH 發送給所需的收件人,并向服務商支付少量費用。

1614666278721

在進行存款時,官方會返回憑據,這樣你在取出時提供相應的憑據即可進行取出

存入了存款,過了一段時間以后,你便可以通過該note將存款取出,而取出時可以使用一個新的地址,這樣,就無法追溯到該筆交易了。

1614666400319

ETH的交易雖然不匿名,因為點對點的傳輸永遠是存在一個可追溯的連接,但是tornado.cash提供了一種任何人都可以向其進行轉發相同存款的方式并提供存款憑證,然后在存款者提供存款憑證時轉給存款者代幣的方式來嘗試去掉這個連接。

區塊鏈透明傳輸環境進行匿名token轉移如何實現

在tornado.cash具體實現中采用了Merkle Tree,用戶每次存款將會調用insert向Merkle Tree中進行結點插入(存款證明)

function deposit(bytes32 _commitment) external payable nonReentrant {
    require(!commitments[_commitment], "The commitment has been submitted");

    uint32 insertedIndex = _insert(_commitment);//插入樹結點
    commitments[_commitment] = true;//證明置為true
    _processDeposit();

    emit Deposit(_commitment, insertedIndex, block.timestamp);
  }
function _insert(bytes32 _leaf) internal returns(uint32 index) {
    uint32 currentIndex = nextIndex;
    require(currentIndex != uint32(2)**levels, "Merkle tree is full. No more leafs can be added");
    nextIndex += 1;
    bytes32 currentLevelHash = _leaf;//_commitment傳遞到currentLevelHash
    bytes32 left;
    bytes32 right;

    for (uint32 i = 0; i < levels; i++) {
      if (currentIndex % 2 == 0) {
        left = currentLevelHash;
        right = zeros[i];

        filledSubtrees[i] = currentLevelHash;
      } else {
        left = filledSubtrees[i];
        right = currentLevelHash;
      }

      currentLevelHash = hashLeftRight(left, right);

      currentIndex /= 2;
    }

    currentRootIndex = (currentRootIndex + 1) % ROOT_HISTORY_SIZE;
    roots[currentRootIndex] = currentLevelHash;//寫入currentLevelHash到Merkle Tree
    return nextIndex - 1;
  }

最終經過添加Merkle Tree葉子節點后給出一個存款證明。形如

當存款者在取錢的時候,則可以通過提供該憑據進行取出,但是這里不禁有人會問,仿佛沒有體現零知識證明?

事實上,在存幣時,真正被提供的數據為:

1614842066482

而在取出時取款者提供的數據則通過note進行解密成為如下參數

_proof:存款證明

_root:表示在哪一個Merkle Tree根

_nullifierHash: 代表是否無效化,置為true則該存款已使用

_recipient : 取款時代幣接受者的地址

1614842152866

傳入數據是根據存款后返回的note進行解密進行填入的,相當于在交易的過程中,你通過存幣后返回的的_proof、_root、_nullifierHash 來證明了你的存款,這便是零知識證明的體現。

在代碼層面上,如何通過_proof、_root、_nullifierHash證明這筆存款呢?

這里就要關注withdraw函數了:

    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);
  }

回撤時采用三部分數據,_root表示對應的Merkle Tree,_nullifierHash同時也表達是否該條存款被使用。

在最后的存款驗證階段使用了一個單獨的驗證器合約,在此地址進行了部署

https://etherscan.io/address/0xce172ce1f20ec0b3728c9965470eaf994a03557a#code

關鍵函數verifyProof,該函數則是零知識驗證的具體實現,載入Proof后將input(uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund)依次加載進vk_x并交給Pairing.pairing進行校驗,具體代碼實現如下:

function verifyProof(
        bytes memory proof,
        uint256[6] memory input// _proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
    ) public view returns (bool) {
        uint256[8] memory p = abi.decode(proof, (uint256[8]));

        // Make sure that each element in the proof is less than the prime q
        for (uint8 i = 0; i < p.length; i++) {
            require(p[i] < PRIME_Q, "verifier-proof-element-gte-prime-q");
        }

        Proof memory _proof;// 初始化Proof
        _proof.A = Pairing.G1Point(p[0], p[1]);// 將dp上的Prove賦值
        _proof.B = Pairing.G2Point([p[2], p[3]], [p[4], p[5]]);
        _proof.C = Pairing.G1Point(p[6], p[7]);

        VerifyingKey memory vk = verifyingKey();// 生成vk

        // Compute the linear combination vk_x
        Pairing.G1Point memory vk_x = Pairing.G1Point(0, 0);
        vk_x = Pairing.plus(vk_x, vk.IC[0]);

        // Make sure that every input is less than the snark scalar field
        for (uint256 i = 0; i < input.length; i++) {
            require(input[i] < SNARK_SCALAR_FIELD, "verifier-gte-snark-scalar-field");
            vk_x = Pairing.plus(vk_x, Pairing.scalar_mul(vk.IC[i + 1], input[i]));// 加載進vk_x
        }

        return Pairing.pairing(
            Pairing.negate(_proof.A),
            _proof.B,
            vk.alfa1,
            vk.beta2,
            vk_x,
            vk.gamma2,
            _proof.C,
            vk.delta2
        );
    }

在通過該驗證后(確認已存款并且該存款未被取出)則進行取款標志置為true并利用_processWithdraw函數進行資產取出

function _processWithdraw(address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund) internal {
    // sanity checks
    require(msg.value == 0, "Message value is supposed to be zero for ETH instance");
    require(_refund == 0, "Refund value is supposed to be zero for ETH instance");

    (bool success, ) = _recipient.call.value(denomination - _fee)("");
    require(success, "payment to _recipient did not go thru");
    if (_fee > 0) {
      (success, ) = _relayer.call.value(_fee)("");
      require(success, "payment to _relayer did not go thru");
    }
  }

具體算法描述如下

要取出樹中位置為 i 的硬幣(k,r),用戶按以下步驟操作:

智能合約驗證了_nullifierHash散列的真實性和唯一性。在驗證成功的情況下,它發送(N?f)到指定地址并將h置為true添加到哈希列表中。

nullifierHashes[_nullifierHash] = true;

思考:tornado.cash真的匿名嗎

Tornado通過使用智能合約打破地址之間的鏈上聯系來改善交易隱私,該合約接受ETH存款,隨后可由不同的地址提取。用戶在存款時需要提供秘密的哈希值,之后在提現時提供zkSnark證明,以顯示對秘密的了解,而不泄露秘密或之前的存款本身。這樣就把提現和存款脫鉤了。而是否這樣已經達到足夠的匿名了呢?顯然不是

  • 在存取款的過程中,仍舊應該使用代理等手段隱藏自己的網絡層數據等信息,ISP可以記錄發送到中繼層的數據包的時間戳,并將它們與取款事務時間戳相關聯。

  • 存取款時間間隔問題,若存取款時間間隔較短,將可能導致存取款交易發生時間關聯,造成匿名性的削減。因此官方建議在存款后過一段時間之后再執行取款操作。

除此之外,在其他混幣服務中可能只設計一個回撤合約調用,這里如果需要直接調用合約并且不涉及接收地址的參數傳遞進行新地址提現則時,要從一個新生成的地址中執行該回撤函數提現交易,那么用戶則需要在里面有一些ETH來支付gas。但這個ETH的來源(一般是交易所)會破壞Tornado的隱私。因此,首選的替代方案是再次使用中繼器網絡。

原本gas的主動支付需要依賴于在KYC后的中心化交易所購買ETH,而替代方案旨在通過將用戶的負擔轉移到中繼器上,以減少這種上鏈用戶體驗摩擦,其成本由錢包提供商鏈上/鏈下和/或用戶鏈下補償。

總結

零知識證明可以讓原本透明的數據(_commitment)進行隱藏和下鏈(note的不可獲取性),但是在下鏈和上鏈過程中的隱私安全不被tornado.cash保護,仍需自行做出一些防護。

下鏈后通過note還原證明數據(_proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund])),該憑據note是恢復你的R(_Root)、h(_nullifierHash)、A(_recipient)、f(費用)、P(_proof)的唯一方式,切不可丟失或遺忘。

最后進行存款的證明并轉給一個新的地址,從而中斷轉賬中透明的數據連接的效果。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1497/