密碼學簽名是區塊鏈關鍵技術之一,不僅可以用來發送交易,還可以與去中心化交易所、多籤合約和其它智能合約交互。

原文標題:《乾貨 | 以太坊上的數字簽名》
撰文:Maarten Zuidhoorn
翻譯 & 校對:閔敏 & 阿劍

密碼學簽名是區塊鏈的關鍵技術之一,可以在不暴露私鑰的前提下證明地址的所有權。該技術主要用來簽署交易(當然也可以用來簽署其他任意消息)。本文會講解數字簽名技術在以太坊協議中的用法。

乾貨 | 以太坊上的數字簽名

什麼是密碼學簽名?

當我們討論密碼學中的簽名時,我們其實是在討論所有權、有效性和完整性證明。舉例來說,這些簽名可以用來:

  • 證明你擁有地址的私鑰(即認證功能);
  • 確保信息(例如,郵件)沒有被篡改;
  • 驗證你下載的 MyCrypto 版本是合法的。

密碼學簽名是基於數學公式的。我們擁有一個輸入消息、一個私鑰和一個(通常情況下是祕密的)隨機數,就可以得到一串數字作爲輸出值,也就是簽名。使用另一個數學公式可以進行反向計算,在不知道私鑰和隨機數的情況下進行驗證(譯者注:即驗證該簽名是否出自跟某個公鑰對應的私鑰)。這類算法有很多,如 RSA 和 AES,但是以太坊(和比特幣)採用的都是橢圓曲線數字簽名算法(ECDSA)。請注意,ECDSA 只是簽名算法。與 RSA 和 AES 不同,這種算法不能用於加密。

乾貨 | 以太坊上的數字簽名橢圓曲線的例子之一。以太坊採用的是 SECP256k1 曲線

通過橢圓曲線點乘算法(elliptic curve point manipulation),我們可以使用私鑰計算出一個不可逆向計算的值(譯者注:即 「公鑰」,公鑰無法逆向計算出私鑰)。這樣一來,我們就可以創建出安全且不可篡改的簽名。能夠生成不可逆向計算的值的函數叫做 「陷門函數(trapdoor function)」:

陷門函數指的是在一個方向上易於計算,但是在缺少特殊信息(即,陷門)的情況下很難反向計算的函數。

使用 ECDSA 簽名並驗證

ECDSA 簽名由兩個數字(整數)組成:rs。以太坊還引入了額外的變量 v(恢復標識符)。簽名可以表示成 {r, s, v}

在創建簽名時,你要先準備好一條待簽署的消息,和用來簽署該消息的私鑰(dₐ)。簡化後的簽名流程如下:

  1. 對待簽署消息進行哈希計算,得到哈希值(e)。
  2. 生成一個安全的隨機數 k
  3. k 乘以橢圓曲線的常量 G,來計算橢圓曲線上的點(x₁, y₁)。
  4. 計算 r = x₁ mod n。如果 r 等於 0,請返回步驟 2 。
  5. 計算 s = k⁻¹(e + rdₐ) mod n。如果 s 等於 0,請返回步驟 2。

在以太坊上,通常使用 Keccak256("\x19Ethereum Signed Message:\n32" + Keccak256(message)) 來計算哈希值。這樣可以確保該簽名不能在以太坊之外使用。

由於 k 是隨機值,我們每次得到的簽名都不一樣。如果 k 的隨機程度不夠高,或者隨機值被泄漏,就有可能使用兩個不同的簽名計算出私鑰【「fault attack」】。但是,如果你在 MyCrypto 內簽署同一條消息,每次得到的輸出值都相同,那麼如何確保其安全性?這些確定性簽名均採用 RFC 6979 標準。該標準描述瞭如何基於私鑰和消息(或哈希值)來生成安全的 k 值。

{r, s, v} 簽名可以組成一個長達 65 字節的序列:r 有 32 個字節,s 有 32 個字節,v 有一個字節。如果我們將該簽名編碼成一個十六進制的字符串,我們最後會得到一個 130 個字符長的字符串。大多數錢包和界面都會使用這個字符串。以 MyCrypto 爲例,一個完整的簽名如下圖所示:

{
  "address": "0x76e01859d6cf4a8637350bdb81e3cef71e29b7c2",
  "msg": "Hello world!",
  "sig": "0x21fbf0696d5e0aa2ef41a2b4ffb623bcaf070461d61cf7251c74161f82fec3a4370854bc0a34b3ab487c1bc021cd318c734c51ae29374f2beb0e6f2dd49b4bf41c",
  "version": "2"
}

在 MyCrypto 的 「驗證消息(Verify Message)」 一頁中,我們可以使用該簽名,並看到該消息是由 0x76e01859d6cf4a8637350bdb81e3cef71e29b7c2 簽署的。

乾貨 | 以太坊上的數字簽名MyCrypto 上的簽名驗證通過。點擊此處,即可體驗

你可能會問:爲什麼要將 addressmsgversion 等其它信息也包括在內?不能只驗證簽名本身嗎?好吧,不能。如果不保留其它信息,就好像簽了一個合同,然後刪除了合同裏的所有信息,只留下當事人的簽名。不同於交易簽名(我們之後會作更深入解釋),消息簽名就只是簽名而已(譯者注:因此只有簽名是沒法驗證的)。

爲了驗證消息,我們需要掌握原始消息、使用私鑰簽署消息的地址,以及 {r, s, v} 簽名本身。版本號就是 MyCrypto 使用的某個版本號。舊版本的 MyCrypto 通常會加上消息的當前日期和時間,計算其哈希值,然後按照上述步驟簽署該消息。後來又進行了更改,以符合 JSON-RPC 方法 personal_sign 方法,因此需要指明版本號(「2」)。

簡化後的公鑰恢復流程如下:

  • 計算消息的哈希值(e)。
  • 計算橢圓曲線上的點 R = (x₁, y₁),其中 x₁ 是 rv = 27),或 r + nv = 28)。
  • 計算 u₁ = -zr⁻¹ mod nu₂ = sr⁻¹ mod n
  • 計算點 Qₐ = (xₐ, yₐ) = u₁ × G + u₂ × R

Qₐ 是地址用來簽名的私鑰所對應的公鑰。我們可以通過公鑰計算出一個地址,並檢查該地址是否與已提供地址相符。如果相符,則簽名有效。

恢復標識符(「v」)

v 是簽名的最後一個字節,而且不是 27 (0x1b) 就是 28 (0x1c)。恢復標識符非常重要,因爲我們使用的是橢圓曲線算法,僅憑 rs 可計算出曲線上的多個點,因此會恢復出兩個不同的公鑰(及其對應地址)。v 會告訴我們應該使用這些點中的哪一個。

在大多數實現中,v 在內部只是 0 或 1,而 27 是在簽署比特幣消息時加上的任意數。以太坊也接受了這一點。

從 EIP-155 開始,我們還使用鏈 ID 來計算 v 值。這可以防止跨鏈重放攻擊:以太坊上籤署的交易無法在以太坊經典上使用,反之亦然。目前,恢復標識符只用來簽署交易而非消息。

簽署交易

目前爲止,我們主要討論了針對消息的簽名。就像消息一樣,交易在發送前也需要簽名。如果你使用 Ledger 和 Trezor 之類的硬件錢包,簽名過程會在硬件內部發生。如果使用私鑰(或 keysotre 文件、助記詞),可以直接在 MyCrypto 上完成簽名。簽署交易所使用的方法與簽署消息非常相似,只不過交易的編碼方式略有不同。

要簽署的交易先用 RLP 編碼方式編碼,包含了所有交易參數(nonce、gas price、gas limit、to、value、data)和簽名(v, r, s)。簽過名的交易如下所示:

0xf86c0a8502540be400825208944bbeeb066ed09b7aed07bf39eee0460dfa261520880de0b6b3a7640000801ca0f3ae52c1ef3300f44df0bcfd1341c232ed6134672b16e35699ae3f5fe2493379a023d23d2955a239dd6f61c4e8b2678d174356ff424eac53da53e17706c43ef871

如果我們在 MyCrypto 的已簽名交易廣播頁面上輸入該交易,我們就會看到所有交易參數:

乾貨 | 以太坊上的數字簽名MyCrypto 的已簽名交易廣播頁面上的交易參數概覽

簽過名的交易的第一組字節包含 RLP 編碼後的交易參數,最後一組字節包含簽名 {r, s, v}。我們可以通過以下方式對簽名交易進行編碼:

  • 交易參數:RLP(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)
  • 使用 Keccak256 算法來計算經過 RLP 編碼的未簽署交易的哈希值。
  • 按照上文講述的步驟,通過 ECDSA 算法,使用私鑰簽署哈希值。
  • 對已簽名的交易進行編碼:RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s)

將經過 RLP 編碼的交易數據解碼後,我們又可以得到原始交易參數和簽名。

請注意,鏈 ID 是被編碼到簽名的 v 參數中的,因此我們不會將鏈 ID 本身包含在最終的簽名交易數據中。我們也不會提供任何發送方地址,因爲地址可以通過簽名恢復。這就是以太坊網絡內部用來驗證交易的方式。

簽名消息的標準化

關於如何爲籤 名消息定義標準結構,人們提出了很多種提議。目前爲止,還沒有一個提議最終確定下來。最初由 Geth 實現的 personal_sign 格式依然是最常見的。儘管如此,有一些提議非常有趣。

我先來簡單介紹下目前創建簽名所採用的方式:

    "\x19Ethereum Signed Message:\n" + length(message) + message

消息通常會預先進行哈希計算,因此長度會固定在 32 個字節:

    "\x19Ethereum Signed Message:\n32" + Keccak256(message)

完整的消息(包括前綴)會再經歷一次哈希計算,然後用私鑰對哈希值簽名。這種方式適用於所有權證明,但是在其它情況下可能會出現問題。例如,如果用戶 A 簽署了一個消息並將其發送給合約 x,用戶 B 可以複製這個已簽署消息併發送給合約 Y。這就叫重放攻擊。有一些提案旨在解決這一問題,如 EIP 191 和 EIP 721。

EIP 191:簽名數據標準

EIP 191 是一個很簡單的提案:它定義了版本號和版本專有數據。格式如下所示:

 0x19 <1 byte version> <version specific data> <data to sign>

顧名思義,版本專有數據(version specific data)取決於我們所使用的版本。目前,EIP 191 有三個版本:

  • 0x00:帶有 「目標驗證者(intended validator)」 的數據。如果是合約,可以是合約地址。
  • 0x01:結構化數據,如 EIP-712 中定義的那樣。關於這點,之後會給出詳細解釋。
  • 0x45:常規的簽過名的消息,如 personal_sign 的當前行爲。

如果我們指定目標驗證者(如,合約地址),該合約可以使用自己的地址來重新計算哈希值。將已簽署消息提交到不同的合約實例是行不通的,因爲後者無法驗證簽名。

由於 0x19 已經被選爲固定的字節前綴,簽名消息無法成爲經過 RLP 編碼的簽名交易,因爲後者永遠不會以 0x19 開頭。

EIP 712:基於以太坊的類型化結構化數據哈希和簽名

請不要將 EIP 712 與非同質化代幣標準 ERC 721 搞混了。EIP 712 是一個關於 「類型化」 已簽署數據的提案。通過人類可讀的方式將數據呈現出來,這樣可以降低數據的驗證難度。

乾貨 | 以太坊上的數字簽名通過 MetaMask 簽署消息。左邊是舊版已簽署消息界面(使用的是 personal_sign,右邊是新版界面(使用的是 EIP-712)

EIP-712 定義了一種新的方法來代替 personal_signeth_signTypedData(最新版用的是 eth_signTypedData_v4)。如果使用這種方法,我們必須指定所有屬性(例如,toamountnonce)及其各自的類型(如,addressuint256uint256),還有該應用的一些基本信息,稱爲域(domain)。

域包含應用名稱、版本、鏈 ID、你正在交互的合約和鹽值(salt)等信息。合約應該驗證這些信息,從而確保同一個簽名不能在不同的應用上使用。這樣可以解決上文提到的重放攻擊問題。

上圖所示消息的具體定義如下:

 {
  types: {
    EIP712Domain: [
      { name: 'name', type: 'string' },
      { name: 'version', type: 'string' },
      { name: 'chainId', type: 'uint256' },
      { name: 'verifyingContract', type: 'address' },
      { name: 'salt', type: 'bytes32' }
    ],
    Transaction: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
      { name: 'nonce', type: 'uint256' }
    ]
  },
  domain: {
    name: 'MyCrypto',
    version: '1.0.0',
    chainId: 1,
    verifyingContract: '0x098D8b363933D742476DDd594c4A5a5F1a62326a',
    salt: '0x76e22a8ee58573472b9d7b73c41ee29160bc2759195434c1bc1201ae4769afd7'
  },
  primaryType: 'Transaction',
  message: {
    to: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
    amount: 1000000,
    nonce: 0
  }
}

如你所見,這個消息在 MetaMask 上是可見的,我們可以確認我們正在簽署的消息就是我們想要執行的。EIP 712 實行 EIP 191,因此數據將以 0x1901 開頭:0x19 是前綴,0x01 是版本字節,表示這是一個 EIP 712 簽名。

通過 Solidity,我們可以爲 Transaction 類型定義一個 struct,並編寫一個函數來對交易進行哈希計算:

struct Transaction {
  address payable to;
  uint256 amount;
  uint256 nonce;
}

function hashTransaction(Transaction calldata transaction) public view returns (bytes32) {
  return keccak256(
    abi.encodePacked(
      byte(0x19),
      byte(0x01),
      DOMAIN_SEPARATOR,
      TRANSACTION_TYPE,
      keccak256(
        abi.encode(
          transaction.to,
          transaction.amount,
          transaction.nonce
        )
      )
    )
  );
}

上述交易的數據如下所示:

0x1901fb502c9363785a728bf2d9a150ff634e6c6eda4a88196262e490b191d5067cceee82daae26b730caeb3f79c5c62cd998926589b40140538f456915af319370899015d824eda913cd3bfc2991811b955516332ff2ef14fe0da1b3bc4c0f424929

上述數據由 EIP-191 字節、哈希域分隔符、哈希後的 Transaction 類型和 Transaction 輸入組成。該數據會再經過一次哈希計算,並進行簽署。然後,我們可以使用 ecrecover 來驗證智能合約中的簽名:

function verify (address signer, Transaction calldata transaction, bytes32 r, bytes32 s, uint8 v) public returns (bool) {
  return signer == ecrecover(hashTransaction(transaction), v, r, s);
}   

在下一節中,我們將詳細解釋 ecrecover。如果你想找一個簡單的 JavaScript 或 TypeScript 代碼庫來來實現 EIP 712,請查看 這個庫

如果你想詳細瞭解如何在智能合約中實現 EIP 712,我建議你閱讀 MetaMask 的這篇文章。遺憾的是,EIP 712 規範目前還是草案,還沒有得到很多應用的支持。目前,Ledger 和 Trezor 都還沒支持 EIP 712,可能會阻礙該規範的廣泛採用。不過,Ledger 表示他們即將發佈的更新版會支持 EIP 712。

通過智能合約來驗證簽名

消息簽名更有趣的地方在於,我們可以使用智能合約來驗證 ECDSA 簽名。Solidity 有一個內置函數叫做 ecrecover(這實際上是地址 0x1 上的預編譯合約),可以恢復用來簽署消息的私鑰的地址。一個(非常)基本的合約實現如下所示:

// SPDX-License-Identifier: MIT
pragma solidity 0.7.0;

contract SignatureVerifier {
  /**
   * @notice Recovers the address for an ECDSA signature and message hash, note that the hash is automatically prefixed with "\x19Ethereum Signed Message:\n32"
   * @return address The address that was used to sign the message
   */
  function recoverAddress (bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address) {
    bytes memory prefix = "\x19Ethereum Signed Message:\n32";
    bytes32 prefixedHash = keccak256(abi.encodePacked(prefix, hash));

    return ecrecover(prefixedHash, v, r, s);
  }

  /**
   * @notice Checks if the recovered address from an ECDSA signature is equal to the address `signer` provided.
   * @return valid Whether the provided address matches with the signature
   */
  function isValid (address signer, bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (bool) {
    return recoverAddress(hash, v, r, s) == signer;
  }
}

該合約僅用於驗證簽名,本身沒有任何用處,因爲簽名驗證也可以在沒有智能合約的情況下完成。

這種方式的用處在於,用戶可以通過免信任方式向智能合約發送某些指令,而無需發送交易。例如,用戶可以簽署一條消息:「請從我的地址向該地址發送 1 個以太幣。」 智能合約可以使用 EIP-712 和 / 或 EIP-1077 標準來驗證簽名者並執行該指令。智能合約中的簽名驗證可用於以下應用:

  • 多籤合約(如 Gnosis Safe);
  • 去中心化交易所;
  • 元交易和 gas 中繼者(如 Gas Station Network)。

但是,如果你想通過正在使用的智能合約錢包簽署消息怎麼辦?我們顯然不能讓錢包智能合約訪問私鑰對吧。ERC 1271 提議了一個標準,可以讓智能合約驗證其它智能合約的簽名。其規範非常簡單:

pragma solidity ^0.7.0;

contract ERC1271 {
  bytes4 constant internal MAGICVALUE = 0x1626ba7e;

  function isValidSignature(
    bytes32_hash, 
    bytes memory_signature
  ) public view returns (bytes4 magicValue);
}

合約必須實現 isValidSignature 函數,該函數可以像上述合約那樣運行任意函數。如果簽名確實是與合約對應的,則函數返回 MAGICVALUE。這樣一來,只要是實現了 ERC 1271 的合約,任何合約都可以驗證其簽名。從內部來說,實現 ERC 1271 的合約可以讓多名用戶簽署同一個消息(例如,在多籤合約的情況下),並將哈希值存儲在內部。然後,該合約可以驗證提供給 isValidSignature 函數的哈希值是否在內部簽署,且簽名是否對合約所有者之一有效。

總結

對於區塊鏈和去中心化來說,簽名非常重要。簽名不僅可以用來發送交易,還可以用來與去中心化交易所、多籤合約和其它智能合約進行交互。目前還沒有明確的消息簽名標準,進一步採用 EIP 712 規範有助於生態系統改善用戶體驗,併爲消息簽名制定標準。

免責聲明:密碼學很難。請不要將本文的任何內容作爲參考,來實現您自己的密碼學函數。雖然我們已經進行了廣泛的研究,但是文本所提供的信息可能仍有不準確之處。本文僅用作教育用途。

參考文獻和相關文章

  • Ethereum: A Secure Decentralised Genralised Transaction Ledger (Yellowpaper)

  • EIP-155: Simple replay attack protection

  • EIP-191: Signed Data Standard

  • EIP-712: Ethereum typed structured data hashing and signing

  • ERC-1271: Standard Signature Validation Method for Contracts

  • RFC6979: Deterministic Usage of the Digital Signature Algorithm (DSA) and Elliptic Curve Digital Signature Algorithm (ECDSA)

來源鏈接:medium.com