以太坊使用的数字签名算法叫双椭圆曲线数字签名算法( ECDSA
),基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要起到了三个作用:
- 身份认证:证明签名方是私钥的持有人。
- 不可否认:发送方不能否认发送过这个消息。
- 完整性:消息在传输过程中无法被修改。
参考
ECDSA标准
签名者利用私钥(隐私的)对消息(公开的)创建签名(公开的)。
其他人使用消息(公开的)和签名(公开的)恢复签名者的公钥(公开的)并验证签名。
我们将配合 ECDSA
库讲解这两个部分。本教程所用的私钥,公钥,消息,以太坊签名消息,签名如下所示:
- 签名者私钥:
0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b
- 签名者公钥:
0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
- 消息:
0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
- 以太坊签名消息:
0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
- 签名:
0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
这里说一下上面的关系:
- 消息: 消息是
创建签名
打包消息: 在以太坊的 ECDSA
标准中,被签名的消息是一组数据的 keccak256
哈希,为 bytes32
类型。我们可以把任何想要签名的内容利用 abi.encodePacked()
函数打包,然后用 keccak256()
计算哈希,作为消息。我们例子中的消息是由一个 address
类型变量和一个 uint256
类型变量得到的:
1 | /* |
计算以太坊签名消息
消息可以是能被执行的交易,也可以是其他任何形式。
为了避免用户误签了恶意交易,EIP191
提倡在消息前加上 \x19Ethereum Signed Message:\n32
字符,并再做一次 keccak256
哈希,作为以太坊签名消息。经过 toEthSignedMessageHash()
函数处理后的消息,不能被用于执行交易。
1 | /** |
处理后的消息为:
以太坊签名消息: 0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
利用钱包签名
日常操作中,大部分用户都是通过这种方式进行签名。在获取到需要签名的消息之后,我们需要使用 metamask
钱包进行签名。
metamask
的 personal_sign
方法会自动把消息转换为以太坊签名消息,然后发起签名。所以我们只需要输入消息和签名者钱包 account
即可。需要注意的是输入的签名者钱包 account
需要和 metamask
当前连接的 account
一致才能使用。
因此首先把例子中的私钥导入到小狐狸钱包,然后打开浏览器的 console
页面
1 | 签名者私钥: 0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b |
Chrome菜单
-更多工具-开发者工具-Console
。在连接钱包的状态下(如连接opensea
,否则会出现错误),依次输入以下指令进行签名:
1 | ethereum.enable() |
在返回的结果中(Promise
的PromiseResult
)可以看到创建好的签名。不同账户有不同的私钥,创建的签名值也不同。利用教程的私钥创建的签名如下所示:
0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
这里需要注意的是,消息 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
是加密了 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
地址,而不是 0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
。
利用 web3.py
就是
1 | from web3 import Web3, HTTPProvider |
运行的结果如下所示。计算得到的消息,签名和前面的案例一致。
消息:0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
签名:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
验证签名
为了验证签名,验证者需要拥有消息,签名,和签名使用的公钥。我们能验证签名的原因是只有私钥的持有者才能够针对交易生成这样的签名,而别人不能。
通过签名和消息恢复公钥:签名是由数学算法生成的。这里我们使用的是 rsv
签名,签名中包含 r, s, v
三个值的信息。而后,我们可以通过 r, s, v
及以太坊签名消息来求得公钥。
下面的 recoverSigner()
函数实现了上述步骤,它利用以太坊签名消息 _msgHash
和签名 _signature
恢复公钥(使用了简单的内联汇编):
1 | // @dev 从_msgHash和签名_signature中恢复signer地址 |
参数分别为:
_msgHash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
_signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
返回
0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
对比公钥并验证签名
接下来,我们只需要比对恢复的公钥与签名者公钥 _signer
是否相等:若相等,则签名有效;否则,签名无效:
1 | /** |
参数分别为:
_msgHash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
_signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c
_signer:0xe16C1623c1AA7D919cd2241d8b36d9E79C1Be2A2
返回 true
。
无消耗 NFT 白名单
虽然 solidity | 默克尔树 可以低成本消耗 gas
来设置白名单,但是,签名的方式,项目方不需要支付 gas
。
具体原理如下
- 创建
NFT
合约的时候,固定一个参数,参数值是公钥A
- 前端将参数相关的数据传递到后端
- 后端通过
公钥A
的私钥对消息进行签名,返回给前端 - 前端拿到消息 + 签名 ,传递给合约
- 合约解析消息 + 签名,看参数是否和
公钥A
是否一致
具体案例请参考
openzeppelin
1 | pragma solidity ^0.8.0; |
参数
hash:0xb42ca4636f721c7a331923e764587e98ec577cea1a185f60dfcc14dbb9bd900b
signature:0x390d704d7ab732ce034203599ee93dd5d3cb0d4d1d7c600ac11726659489773d559b12d220f99f41d17651b0c1c6a669d346a397f8541760d6b32a5725378b241c