# 以太坊签名验签原理揭秘

By [Renaissance Labs](https://paragraph.com/@renaissance-labs) · 2022-03-28

---

签名三大作用
------

讨论密码学中的签名时，我们其实是在讨论所有权、有效性和完整性证明。举例来说，这些签名可以用来：

1.  证明你拥有地址的私钥（即认证功能）；
    
2.  确保信息（例如，邮件）没有被篡改；
    
3.  验证你下载的 文件是合法有效的。
    

签名基础
----

> 原理:基于数学公式

输入：一个输入消息、一个私钥和一个（通常情况下是秘密的）随机数，就可以得到一串数字作为输出值，也就是签名。

输出：使用另一个数学公式可以进行反向计算，在不知道私钥和随机数的情况下进行验证（译者注：即验证该签名是否出自跟某个公钥对应的私钥）。

这类算法有很多，如 RSA 和 AES，但是以太坊（和比特币）采用的都是椭圆曲线数字签名算法（ECDSA）。请注意，ECDSA 只是签名算法。与 RSA 和 AES 不同，这种算法不能用于加密。以太坊采用的是 `secp256k1`曲线。

签名方案由哈希算法和签名算法组成。以太坊选择的签名算法是`secp256k1`，哈希算法选择了`keccak256`，这是一个从字节串。

不可逆计算
-----

通过椭圆曲线点乘算法（elliptic curve point manipulation），我们可以使用私钥计算出一个不可逆向计算的值（译者注：即 “公钥”，公钥无法逆向计算出私钥）。这样一来，我们就可以创建出安全且不可篡改的签名。

陷门函数
----

能够生成不可逆向计算的值的函数叫做 “陷门函数（trapdoor function）”。

陷门函数指的是在一个方向上易于计算，但是在缺少特殊信息（即，陷门）的情况下很难反向计算的函数。

ECDSA 签名过程
----------

ECDSA 签名由两个数字（整数）组成：r 和 s。以太坊还引入了额外的变量 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))来计算哈希值。这样可以确保该签名不能在以太坊之外使用。

以太坊具体关键代码如下：

    func signHash(data []byte) []byte {
       msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(data), data)
       return crypto.Keccak256([]byte(msg))
    }
    

由于 k 是随机值，我们每次得到的签名都不一样。如果 k 的随机程度不够高，或者随机值被泄漏，就有可能使用两个不同的签名计算出私钥，进而导致著名的Fault attack。但是，如果你在 MyCrypto 内签署同一条消息，每次得到的输出值都相同，那么如何确保其安全性？这些确定性签名均采用 RFC 6979 标准。

{r, s, v} 签名可以组成一个长达 65 字节的序列：r 有 32 个字节，s 有 32 个字节，v 有一个字节。如果我们将该签名编码成一个十六进制的字符串，我们最后会得到一个 130 个字符长的字符串。大多数钱包和界面都会使用这个字符串。一个完整的签名示例如下图所示：

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

在钱包的 “验证消息（Verify Message）” 一页中，我们可以使用该签名，并看到该消息是由 0x76e01859d6cf4a8637350bdb81e3cef71e29b7c2 签署的。

为什么要将 address、msg 和 version 等其它信息也包括在内？不能只验证签名本身吗？不能。如果不保留其它信息，就好像签了一个合同，然后删除了合同里的所有信息，只留下当事人的签名。因为只有签名是没法验证的。下个章节会有讲到？

ECDSA 签名验证
----------

为了验证消息，我们需要掌握原始消息、使用私钥签署消息的地址，以及 {r, s, v} 签名本身。版本号就是使用的某个版本号。以符合 JSON-RPC 方法personal\_sign 方法，因此需要指明版本号（“2”）。

简化后的公钥恢复流程如下：

1.  计算消息的哈希值（e）。
    
2.  计算椭圆曲线上的点 R = (x, y)，其中 x 是 r（v = 27），或 r + n（v = 28）。
    
3.  计算 u = -zr mod n 和 u = sr mod n。
    
4.  计算点 Q = (x, y) = u × G + u × R。
    

Q 是地址用来签名的私钥所对应的公钥。我们可以通过公钥计算出一个地址，并检查该地址是否与已提供地址相符。如果相符，则签名有效。

### 恢复标识符（“v”）

v 是签名的最后一个字节，而且不是 27 (0x1b) 就是 28 (0x1c)。恢复标识符非常重要，因为我们使用的是椭圆曲线算法，仅凭r 和 s 可计算出曲线上的多个点，因此会恢复出两个不同的公钥（及其对应地址）。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）。签过名的交易如下所示：

签过名的交易的第一组字节包含 RLP 编码后的交易参数，最后一组字节包含签名 {r, s, v}。

我们可以通过以下方式对签名交易进行编码：

1.  交易参数：RLP(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)。
    
2.  使用 Keccak256 算法来计算经过 RLP 编码的未签署交易的哈希值。
    
3.  按照上文讲述的步骤，通过 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)`

完整的消息（包括前缀）会再经历一次哈希计算，然后用私钥对哈希值签名。这种方式适用于所有权证明，但是在其它情况下可能会出现问题。

关键词解读
-----

### EIP 191

EIP 191 是一个很简单的提案：它定义了版本号和版本专有数据。格式如下所示：

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

版本专有数据（version specific data）取决于我们所使用的版本。目前，EIP 191 有三个版本：

1.  0x00：带有 “目标验证者（intended validator）” 的数据。如果是合约，可以是合约地址。
    
2.  0x01：结构化数据，如 EIP-712 中定义的那样。
    
3.  0x45：常规的签过名的消息，如 personal\_sign 的当前行为。如果我们指定目标验证者（如，合约地址），该合约可以使用自己的地址来重新计算哈希值。将已签署消息提交到不同的合约实例是行不通的，因为后者无法验证签名。
    

由于 0x19 已经被选为固定的字节前缀，签名消息无法成为经过 RLP 编码的签名交易，因为后者永远不会以 0x19 开头。

### EIP 712

请不要将 EIP 712 与非同质化代币标准 ERC 721 搞混了。EIP 712 是一个关于 “类型化” 已签署数据的提案。通过人类可读的方式将数据呈现出来，这样可以降低数据的验证难度。

![](https://storage.googleapis.com/papyrus_images/ee6d50c266ffdb1d12dc2898860e5e7d7478516199aa0a8b6d343657242613c7.png)

\- 通过 MetaMask 签署消息。左边是旧版已签署消息界面（使用的是 personal\_sign，右边是新版界面（使用的是 EIP-712）。

EIP-712 定义了一种新的方法来代替 personal\_sign：eth\_signTypedData（最新版用的是 eth\_signTypedData\_v4）。如果使用这种方法，我们必须指定所有属性（例如，to、amount 和 nonce）及其各自的类型（如，address、uint256 和 uint256），还有该应用的一些基本信息，称为域（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);
    }
    

### ERC 1271

如果你想通过正在使用的智能合约钱包签署消息怎么办？我们显然不能让钱包智能合约访问私钥对吧。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 函数的哈希值是否在内部签署，且签名是否对合约所有者之一有效。

### RLP编码

RLP(Recursive Length Prefix，递归长度前缀)是一种序列化编码算法，用于编码任意的嵌套结构的二进制数据。RLP序列化方法因为简单、短小等诸多优点，现如今已经成为以太坊中数据序列化/反序列化的主要方法，区块、交易等数据结构在持久化时会先经过RLP编码后再存储到数据库中。

### Keccak256

_keccak256_算法则可以将任意长度的输入压缩成64位16进制的数,且哈希碰撞的概率近乎为0。

### Fault attack

ECDSA签名算法的安全性是比较依赖于安全的随机数生成算法的，如果随机数算法存在问题，使用了相同的k进行签名，那么攻击者是可以根据签名信息恢复私钥的Fault attack，历史上也出过几次这样的事故，比如10年索尼的PS3私钥遭破解以及12年受java某随机数生成库的影响造成的比特币被盗事件，关于这部分内容我也写过相关的分析，可以参见[利用随机数冲突的ECDSA签名恢复以太坊私钥](https://xz.aliyun.com/t/2718)，所以说ECDSA签名在设计上还是存在一些问题的， 这也激励了新的EdDSA算法的出现。

### 重放攻击

如果用户 A 签署了一个消息并将其发送给合约 x，用户 B 可以复制这个已签署消息并发送给合约 Y。这就叫重放攻击。有一些提案旨在解决这一问题，如 EIP 191 和 EIP 721。

### RFC 6979 标准

该标准描述了如何基于私钥和消息（或哈希值）来生成安全的 k 值。

随机系列谈的童鞋现在应该明白k值对于比特币的重要性（不仅仅是比特币，对于整个椭圆曲线家族来说都是如此）了吧？暴漏k值（签名）相当于暴漏私钥，因此： k值必须是保密且唯一的，并不一定必须随机！

由于历史上发生过太多次伪随机数失败案例，有人想出了一种用“确定性”方式来产生k值的方法，同样保证了“保密”且“唯一”，最后成为一个编号为6979的规范，即：RFC6979。

为了确定性的产生保密且唯一的k值，我们先试着写出这么一个简单的公式：

`k = SHA256(d + HASH(m))`;

其中，d是私钥，m是消息，我们一般会对消息的HASH进行签名，因此这里是HASH(m)。

好了，满足我们的需求其实只需要这么个简单的公式就足够了，因为参数里有私钥d，就保证了“保密”，再加上消息m，保证了“唯一”，这也是“确定性”的算法，只要SHA256是安全的，此算法就是安全的，很完美。

如果仅仅是针对比特币而言，这个公式已经很好了，但考虑到RFC6979面向的是密码学（不仅仅是比特币）的统一规范，要考虑更多的复杂情况（更多曲线、更多参数、更多算法等），因此，实际上的RFC6979要比上述公式复杂得多，代码实现起来也要多得多。

算法可以复杂，代码可以很长，但原理都一样，要用私钥来保证“保密”，要用消息来保证“唯一”，再使用确定的、不可逆的方法来进行运算，最终计算出来的k值就是安全的。

RFC6979算法的完整实现，Java语言可参考bitherj项目所依赖的SpongyCastle中HMacDSAKCalculator类，Objective-C语言可参考bitheri项目。

参考
--

1.  [https://www.anquanke.com/post/id/167018](https://www.anquanke.com/post/id/167018)
    
2.  [http://tools.ietf.org/html/rfc6979](http://tools.ietf.org/html/rfc6979)
    
3.  [https://github.com/Mrtenz/eip-712](https://github.com/Mrtenz/eip-712)
    
4.  [https://github.com/bither/](https://github.com/bither/)
    
5.

---

*Originally published on [Renaissance Labs](https://paragraph.com/@renaissance-labs/MUtTNef6tPhcFB6fxPSe)*
