# NFT白名单校验-数字签名

By [cyptoJune](https://paragraph.com/@cyptojune) · 2022-09-02

---

如果大家用过钱包，相信对于签名都不会陌生。

![Metamask钱包签名](https://storage.googleapis.com/papyrus_images/e9c5281b7574c05dbafea3e07601a5cc48d07a10670d6a21dd7c4fd02749bf1e.jpg)

Metamask钱包签名

以太坊使用的数字签名算法叫**双椭圆曲线数字签名算法（**`ECDSA`**）**，基于双椭圆曲线“私钥-公钥”对的数字签名算法。它主要有三种作用：

1.  **身份认证**：证明签名方时私钥的持有人
    
2.  **不可否认**：发送方不能否认发送过这个消息
    
3.  **完整性**：消息在传输过程中无法被修改
    
    在openzeppelin中提供了现成的[ECDSA.sol](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol)合约，具体大家可以去官网看
    

**创建签名**

创建签名分为两步，第一步是**打包消息**，第二步是**计算以太坊签名消息**

1.  **打包消息：** 在以太坊的`ECDSA`标准中，被签名的`消息`是一组数据的`keccak256`哈希，为`bytes32`类型。我们可以把任何想要签名的内容利用`abi.encodePacked()`函数打包，然后用`keccak256()`计算哈希，作为`消息。`
    
         /*
             * 将mint地址（address类型）和tokenId（uint256类型）拼成消息msgHash
             * _account: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
             * _tokenId: 0
             * 对应的消息msgHash: 0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c
             */
            function getMessageHash(address _account, uint256 _tokenId) public pure returns(bytes32){
                return keccak256(abi.encodePacked(_account, _tokenId));
            }
        
    
2.  **计算以太坊签名消息：** `消息`可以是能被执行的交易，也可以是其他任何形式。为了避免用户误签了恶意交易，`EIP191`提倡在`消息`前加上`"\x19Ethereum Signed Message:\n32"`字符，并再做一次`keccak256`哈希，作为`以太坊签名消息`。经过`toEthSignedMessageHash()`函数处理后的消息，不能被用于执行交易。
    
    \[ /\*\* \* @dev 返回 以太坊签名消息 \* \`hash\`：消息哈希 \* 遵从以太坊签名标准：[https://eth.wiki/json-rpc/API#eth\_sign\\\[\\\`eth\_sign\\\`\\\]](https://eth.wiki/json-rpc/API#eth_sign%5C%5B%5C%60eth_sign%5C%60%5C%5D) \* 以及\`EIP191\`:[https://eips.ethereum.org/EIPS/eip-191\\\`](https://eips.ethereum.org/EIPS/eip-191%5C%60) \* 添加"\\x19Ethereum Signed Message:\\n32"字段，防止签名的是可执行交易。 \*/ function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) { // 哈希的长度为32 return keccak256(abi.encodePacked("\\x19Ethereum Signed Message:\\n32", hash)); }\]( /\*\* \* @dev 返回 以太坊签名消息 \* \`hash\`：消息哈希 \* 遵从以太坊签名标准：[https://eth.wiki/json-rpc/API#eth\_sign\\\[\\\`eth\_sign\\\`\\\]](https://eth.wiki/json-rpc/API#eth_sign%5C%5B%5C%60eth_sign%5C%60%5C%5D) \* 以及\`EIP191\`:[https://eips.ethereum.org/EIPS/eip-191\\\`](https://eips.ethereum.org/EIPS/eip-191%5C%60) \* 添加"\\x19Ethereum Signed Message:\\n32"字段，防止签名的是可执行交易。 \*/ function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) { // 哈希的长度为32 return keccak256(abi.encodePacked("\\x19Ethereum Signed Message:\\n32", hash)); })
    

**签名工具**

一般我们用两种方法进行签名，一种是钱包签名，另外一种是脚本签名。

1.  **钱包签名**
    
    日常操作中，大部分用户都是通过这种方式进行签名。在获取到需要签名的消息之后，我们需要使用`metamask`钱包进行签名。`metamask`的`personal_sign`方法会自动把`消息哈希`转换为`以太坊签名消息`，然后发起签名。所以我们只需要输入`消息哈希hash`和`签名者钱包account`即可。需要注意的是输入的`签名者钱包account`需要和`metamask`当前连接的account一致才能使用。
    
2.  **脚本签名**
    
    **利用web3.py签名：** 批量调用中更倾向于使用代码进行签名，具体的代码可自行搜索，下面我给出一段.py的参考代码
    
    \[from web3 import Web3, HTTPProvider
    

from eth\_account.messages import encode\_defunct

private\_key = "0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b" address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" rpc = '[https://rpc.ankr.com/eth](https://rpc.ankr.com/eth)' w3 = Web3(HTTPProvider(rpc))

#打包信息 msg = Web3.solidityKeccak(\['address','uint256'\], \[address,0\]) print(f"消息：{msg.hex()}") #构造可签名信息 message = encode\_defunct(hexstr=msg.hex()) #签名 signed\_message = w3.eth.account.sign\_message(message, private\_key=private\_key) print(f"签名：{signed\_message\['signature'\].hex()}")\](from web3 import Web3, HTTPProvider from eth\_account.messages import encode\_defunct

private\_key = "0x227dbb8586117d55284e26620bc76534dfbd2394be34cf4a09cb775d593b6f2b" address = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" rpc = '[https://rpc.ankr.com/eth](https://rpc.ankr.com/eth)' w3 = Web3(HTTPProvider(rpc))

#打包信息 msg = Web3.solidityKeccak(\['address','uint256'\], \[address,0\]) print(f"消息：{msg.hex()}") #构造可签名信息 message = encode\_defunct(hexstr=msg.hex()) #签名 signed\_message = w3.eth.account.sign\_message(message, private\_key=private\_key) print(f"签名：{signed\_message\['signature'\].hex()}"))

**验证签名**

    上面讲解的是一些数字签名的基本知识，回到本文中心，我们探讨如何在实际项目中利用数字签名进行Nft的白名单的发放。我们知道，我们平时使用的区块链地址，**都分为私钥和地址。私钥可以生成地址，而反过来，地址不能生成私钥。那么如何证明一个地址是属于某一个人呢？我们可以给他一段信息，让他用私钥对这段信息进行签名。我们拿到这段签名，再结合这个地址，就可以证明，这段信息是否是由这个地址对应的私钥签名，从而也就证明了地址的所有权。**
    

上面这段原理理解之后，就可以运用到实际项目开发中。

**验证逻辑**

1.  项目方后台数据库中保存所有的白名单用户
    
2.  用户在网站连接钱包后，前端将用户地址发送给后端
    
3.  后端检查该地址是否是白名单用户，如果是，则用后端的管理员私钥对地址进行签名
    
4.  后端返回签名数据，同样，前端会将签名数据传给合约验证
    
5.  合约验证通过，则用户可以白名单 mint
    
    上面我们建议使用OpenZeppelin的ECDSA库进行引用，在项目实际开发中，主要调用的方法是
    
        function recover(bytes32 hash, bytes memory signature) internal pure returns (address) {
            (address recovered, RecoverError error) = tryRecover(hash, signature);
            _throwError(error);
            return recovered;
        }
        
    
6.  hash → 原始待签名信息的哈希值
    
7.  signature → 签名数据 **返回值是对这段信息签名的地址，也就是我们前面在步骤 3 中所说的管理员地址。也就是说，我们需要将管理员的地址设置在合约中进行比对**。
    

通过上面的验证逻辑，可分为链上和链下进行。

*   **链上部分**
    
    链上部分主要是对签名数据进行验证，看是否是管理员的私钥进行签名。
    
        pragma solidity 0.8.13;
        
        import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
        
        contract TestSig {
        
            // 使用 using 方法，就可以直接使用 bytes32 类型调用方法
            using ECDSA for bytes32;
        
            address public owner;
        
            constructor() {
                // 管理员地址（仅测试，不要对这个地址转账）
                owner = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266;
            }
        
            function verify(bytes memory signature) public view returns (bool) {
                // 验证签名者是否是管理员
                return recoverSigner(signature) == owner;
            }
        
            function recoverSigner(bytes memory signature) public view returns (address) {
                // 注意这里待哈希的内容需要与链下签名方法保持一致即可
                // 可以加盐或者其他数据来保持唯一性，防止重放攻击
                // 这里简单起见，仅对调用者的地址进行哈希签名
                bytes32 messageHash = keccak256(abi.encodePacked(msg.sender));
                // 调用 recover 验证签名地址
                address signer = messageHash.toEthSignedMessageHash().recover(signature);
                return signer;
            }
        }
        
    
    **链下部分**
    
    链下部分使用 `ethers` 的 JavaScript 库进行签名操作，需要安装 `ethers` 的依赖。
    
        npm install --save-dev ethers
        
    
        const ethers = require('ethers');
        const main = async () => {
            // 管理员地址与私钥（仅测试，不要对这个地址转账）
            const owner = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266';
            const privateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
            const signer = new ethers.Wallet(privateKey);
            console.log(signer.address)
        
            // 由于我们之前是使用了 msg.sender 进行签名
            // 因此这里需要改为合约的调用地址
            let message = '0xxxxx';
            // 计算信息的哈希值
            let messageHash = ethers.utils.solidityKeccak256(['address'], [message]);
            console.log("Message Hash: ", messageHash);
            // 由于 ethers 库的要求，需要先对哈希值数组化
            let messageBytes = ethers.utils.arrayify(messageHash);
            console.log("messageBytes: ", messageBytes);
            // 签名
            let signature = await signer.signMessage(messageBytes);
            console.log("Signature: ", signature);
        }
        const runMain = async () => {
            try {
                await main();
                process.exit(0);
            }
            catch (error) {
                console.log(error);
                process.exit(1);
            }
        };
        runMain();
        
    

这段代码就可以为我们生成签名数据，我们将签名数据传入合约的 `verify` 方法，就可以验证签名信息的准确性。

**注：我们这里的签名信息仅仅是对合约发送者进行签名，这可能会造成签名信息被重复利用从而造成重放攻击。因此我们一般在实际项目开发中都会加上一些其他的信息，比如合约本身的地址（这样签名就确认只能应用于这个合约），或者盐（随机数确保随机）**

如果加上合约本身的地址作源信息，那么代码就需要改成：

    // Solidity
    bytes32 messageHash = keccak256(abi.encodePacked(address(this), msg.sender));
    
    // Javascript
    let messageHash = ethers.utils.solidityKeccak256(['address', 'address'], [message, message]);
    

**总结：**

---

*Originally published on [cyptoJune](https://paragraph.com/@cyptojune/nft-3)*
