链下签名

什么是签名

比如我们在使用 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]})

点击签名

签名成功后,得到签名:

  1. 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;
}
posted @ 2022-12-30 23:06  这个杀手冷死了  阅读(73)  评论(0编辑  收藏  举报