什么是签名
比如我们在使用 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
接着生成签名,这里有两种方式:
- 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 () {
// 0xc783df8a850f42e7f7e57013759c285caa701eb6
let privateKey = '0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122'
console.log('private:', privateKey);
const signer = new ethers.Wallet(privateKey);
console.log('address :', signer.address);
const amount = 100
let 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;
}