什么是签名

比如我们在使用 opensea 的时候,经常会提示我们进行数字签名,如下图:

用户进行 sign 确认,即用自己的私钥对一段数据进行签名,得到一个 signature,其他人可以使用你私钥对应的公钥,对 signature 进行验证,从而证明你是私钥的持有者。签名后的数据有如下作用:

  • 验证身份:验证私钥持有人
  • 完整性:防止数据被篡改
  • 不可否认:持有人无法否认签名

我们在区块链中发起的每一笔交易(转账、对合约写操作)都是使用私钥签名过的,矿工会在打包前对每笔交易进行校验。具体逻辑如图:

签名的核心:

  • 使用私钥进行签名,公钥进行验证
  • 不对原文进行签名,而是对原文的hash进行签名(为什么这样做呢?主要是因为 hash 计算的不可逆可以防篡改,如果是对原文签名,中间人攻击可以将密文和原文都改了,最后解密出来数据还是一致的,但其实数据已经被篡改了)

ECDSA 合约

我们将对 openzeppelin 中的 ECDSA 标准合约进行拆解学习,整个签名验证过程可分为四个阶段:

  • 阶段一:打包原始消息,生成 hash
  • 阶段二:添加前缀,生成以太坊签名 hash,用于最终校验
  • 阶段三:解析签名,获得解析的地址 1
  • 阶段四:校验地址 1 与实际签名的地址是否一致

阶段一:打包原始消息

在以太坊的 ECDSA 标准中,被签名的消息为一组数据的 hash 值(由 keccak256 算法生成的 byte32 类型的数据),我们可以使用abi.encodePacked(打包函数)将任意多个参数进行打包,此处为:address 和uint256 类型

function getMessageHash(address _to, uint _amount) public pure returns(bytes32) {return keccak256(abi.encodePacked(_to, _amount));}

输入参数:0xc783df8a850f42e7f7e57013759c285caa701eb6, 100
输出:0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36

阶段二:生成以太坊签名 hash

原始的消息可以是能被执行的交易,也可以是其他任何形式。为避免用户误签了恶意交易,EIP191 提倡在消息前加上前缀 prefix:“\x19Ethereum Signed Message:\n32” 字符,并再做一次 keccak256 哈希,作为以太坊签名消息。经过 getEthSignedMessageHash() 函数处理后的消息,不能被用于执行交易

function getEthSignedMessageHash(bytes32 _messageHash) public pure returns(bytes32) {return keccak256(// 这是标准字符串: \x19Ethereum Signed Message:\n// 32 表示后面的哈希内容长度abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash));}

输入参数:0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36
输出:0x60a7e355f6d1a5885594e145ce67bd165a3e63337806f576b7b417d31cdb20da

接着生成签名,这里有两种方式:

  1. metamask 生成签名
    复制 metamask 账户地址,F12 打开控制台 -> console,输入如下内容然后回车(注意这里的 hash 使用的是“消息 hash”):
ethereum.send('eth_requestAccounts')account = "0xFA172d92bC2A12bD780757927B31E3B2CEdE9950"hash = "0xcfb170482914a76ca8521405f52699df67c7ebb8e3899f27cc8265ebdab98a36"ethereum.request({method: "personal_sign", params: [account, hash]})

点击签名

签名成功后,得到签名:

2. etherjs 生成签名
在hardhat的test文件夹下创建sign.ts

const { expect } = require("chai")const { ethers } = require("hardhat")describe("Signature", function () {it("signature", async function () {// 0xc783df8a850f42e7f7e57013759c285caa701eb6let privateKey = '0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122'console.log('private:', privateKey);const signer = new ethers.Wallet(privateKey);console.log('address :', signer.address);const amount = 100let msgHash = ethers.utils.solidityKeccak256(["address", "uint256"], [signer.address, amount])console.log('msgHash:', msgHash);const sig = await signer.signMessage(ethers.utils.arrayify(msgHash))console.log('signature:', sig);})})

运行单元测试:npx hardhat test,可以得到相同的签名

阶段三:恢复地址

先对 signature 签名分割得到 r, s, v ,然后结合以太坊签名消息,利用内联汇编得出公钥(即 metamask 账户地址),下面的 recoverSigner() 函数实现了上述步骤:

function recoverSigner(bytes32 _ethSignedMessageHash, bytes memory _signature) public pure returns (address) {(bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);// 返回解析出来的签名地址return ecrecover(_ethSignedMessageHash, v, r, s);}// 分割签名function splitSignature(bytes memory sig) public pure returns(bytes32 r, bytes32 s, uint8 v) {require(sig.length == 65, "invalid signature length");// 通过读取内存数据 根据规则进行截取 返回 r, s, v 数据assembly {r := mload(add(sig, 32))s := mload(add(sig, 64))v := byte(0, mload(add(sig, 96)))}}

阶段四:验证

接下来,我们只需要比对恢复的公钥与签名者公钥 _signer 是否相等。若相等,则签名有效;否则,签名无效

function verify(bytes32 _ethSignedMessageHash, bytes memory _signature, address _signer) public pure returns(bool) {return recoverSigner(_ethSignedMessageHash, _signature) == _signer;}

链下签名实现白名单

核心逻辑

  • 将白名单用户地址和 tokenId 签名入库
  • 用户 mint 铸造时,传入签名,在 mint 中进行校验,只有校验为 true 的用户才可以 mint,从而完成白名单功能
function mint(uint256 _tokenId, bytes memory _signature) external {// 将用户地址和_tokenId打包消息bytes32 _msgHash = getMessageHash(msg.sener, _tokenId); // 计算以太坊签名消息bytes32 _ethSignedMessageHash = getEthSignedMessageHash(_msgHash);// ECDSA检验通过require(verify(_ethSignedMessageHash, _signature), "Invalid signature");// 地址没有mint过require(!mintedAddress[_account], "Already minted!"); // 铸造_mint(_account, _tokenId);// 铸造记录mintedAddress[_account] = true;}