Solidity 智能合约进阶 2| 安全性和验证 验证签名

在 Solidity 中验证签名是一项关键操作,用于确认链下消息或交易确实由某个以太坊账户签署。这常用于白名单验证、元交易、链下授权等场景。Solidity 提供了内置的 ecrecover 函数来从签名中恢复出签名者的地址,通过与预期地址比对即可完成验证。

1. 数字签名基础

一个数字签名通常由三部分组成:

  • 消息:需要签名的原始数据。
  • 私钥:签名者持有的密钥。
  • 签名 :对消息哈希使用私钥生成的固定长度字节串(以太坊签名通常为 65 字节,由 rsv 组成)。

验证签名的目的:给定消息和签名,确定签名者的公钥(对应地址)是否与预期一致。

2. Solidity 中的签名验证函数:ecrecover

ecrecover 是一个预编译合约(地址 0x01),输入签名参数,返回签名者的地址。

函数签名

solidity 复制代码
ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address)
  • hash:被签名的消息的哈希(32 字节)。
  • v:签名恢复标识符(通常是 27 或 28,或链 ID 相关值)。
  • r:签名输出的前 32 字节。
  • s:签名输出的后 32 字节。

返回值 :签名者的地址,如果恢复失败则返回 address(0)

3. 如何准备待签名的消息哈希

3.1 简单消息(EIP-191)

以太坊签名规范 EIP-191 规定,对任意消息签名时,应添加前缀 "\x19Ethereum Signed Message:\n" + len(message),然后对拼接后的数据进行 Keccak-256 哈希。这样防止签名被误用于交易等场景。

链下签名(例如使用 MetaMask)

solidity 复制代码
// 使用 ethers.js
const message = "Hello, World!";
const signature = await signer.signMessage(message); // 自动添加 EIP-191 前缀

Solidity 中验证

solidity 复制代码
function verifyMessage(
    address expectedSigner,
    string memory message,
    bytes memory signature
) public pure returns (bool) {
    bytes32 messageHash = keccak256(abi.encodePacked(message));
    bytes32 ethSignedMessageHash = keccak256(
        abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
    );
    // 或者更通用地处理任意长度消息:
    // bytes32 ethSignedMessageHash = keccak256(
    //     abi.encodePacked("\x19Ethereum Signed Message:\n", Strings.toString(bytes(message).length), message)
    // );

    (bytes32 r, bytes32 s, uint8 v) = splitSignature(signature);
    address recovered = ecrecover(ethSignedMessageHash, v, r, s);
    return recovered == expectedSigner;
}

function splitSignature(bytes memory sig) internal pure returns (bytes32 r, bytes32 s, uint8 v) {
    require(sig.length == 65, "Invalid signature length");
    assembly {
        r := mload(add(sig, 32))
        s := mload(add(sig, 64))
        v := byte(0, mload(add(sig, 96)))
    }
    // 调整 v 值(有些库返回 0/1,有些返回 27/28)
    if (v < 27) v += 27;
}

3.2 结构化数据(EIP-712)

EIP-712 定义了结构化数据签名标准,使签名对用户可读,并防止跨域重放。它要求定义一个域分隔符(domain separator)和结构化消息类型,然后计算最终哈希。

Solidity 验证示例(通常结合 OpenZeppelin 的 ECDSA 和 EIP712 库):

solidity 复制代码
// 定义域分隔符和消息类型
bytes32 constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 constant PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

// 计算域分隔符
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    DOMAIN_TYPEHASH,
    keccak256(bytes("MyDApp")),
    keccak256(bytes("1")),
    block.chainid,
    address(this)
));

// 计算结构化消息哈希
bytes32 structHash = keccak256(abi.encode(
    PERMIT_TYPEHASH,
    owner,
    spender,
    value,
    nonce,
    deadline
));

// EIP-712 最终的哈希
bytes32 hash = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));

// 验证签名
address signer = ecrecover(hash, v, r, s);
require(signer == owner, "Invalid signature");

4. 使用 OpenZeppelin 库简化验证

OpenZeppelin 提供了 ECDSA 库,封装了签名分割和验证逻辑,并处理了常见的安全问题(如防止签名 malleability)。

安装npm install @openzeppelin/contracts

使用示例

solidity 复制代码
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract Verifier {
    using ECDSA for bytes32;

    function verify(bytes32 hash, bytes memory signature, address expectedSigner) public pure returns (bool) {
        address recovered = hash.toEthSignedMessageHash().recover(signature);
        return recovered == expectedSigner;
    }

    // 对于 EIP-712 哈希
    function verifyTyped(bytes32 hash, bytes memory signature, address expectedSigner) public view returns (bool) {
        address recovered = hash.recover(signature); // 注意:recover 内部会尝试 EIP-191/712 前缀?需要区分
        return recovered == expectedSigner;
    }
}

OpenZeppelin 的 ECDSA.recover 会自动处理 v 值调整,并确保签名有效。

5. 安全注意事项

  • 重放攻击 :签名可能被多次使用。应引入 noncedeadline 或链 ID 等机制,确保签名只被使用一次或在特定上下文中有效。
  • 签名可塑性 :某些签名可能存在两个有效的 (r, s, v) 组合。OpenZeppelin 的 ECDSA 库会检查 s 是否在低范围(s <= secp256k1n/2),防止可塑性攻击。
  • ecrecover 返回 0 :如果签名无效,ecrecover 会返回 address(0),务必检查返回值不为 0。
  • 域分隔符 :使用 EIP-712 时,务必包含 chainIdverifyingContract 地址,防止签名在不同链或合约间重放。
  • 不要信任未经检查的签名:始终假设签名可能由任何人伪造,除非通过密码学验证。

6. 完整示例:白名单验证

假设有一个白名单,用户需提供签名证明自己有权 mint。

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract WhitelistMint {
    using ECDSA for bytes32;

    address public signer; // 授权签名者的地址
    mapping(address => bool) public hasMinted;

    constructor(address _signer) {
        signer = _signer;
    }

    // 用户需提供签名才能 mint
    function mint(bytes calldata signature) external {
        require(!hasMinted[msg.sender], "Already minted");

        // 构造消息:包含用户地址防止他人使用签名
        bytes32 messageHash = keccak256(abi.encodePacked(msg.sender));
        bytes32 ethSignedHash = messageHash.toEthSignedMessageHash();
        address recovered = ethSignedHash.recover(signature);

        require(recovered == signer, "Invalid signature");

        hasMinted[msg.sender] = true;
        // 执行 mint 逻辑...
    }
}

链下生成签名(Node.js 使用 ethers.js):

solidity 复制代码
const signer = new ethers.Wallet(privateKey); // 白名单签名私钥
const userAddress = "0x..."; // 用户地址
const messageHash = ethers.utils.solidityKeccak256(["address"], [userAddress]);
const signature = await signer.signMessage(ethers.utils.arrayify(messageHash));
// signature 提交给合约的 mint 函数

7. 常见错误

  • 忘记添加 EIP-191 前缀 :链下 signMessage 自动添加,但 Solidity 中必须手动添加 toEthSignedMessageHash,否则验证失败。
  • 签名分割错误v 值可能是 0/1 或 27/28,需统一处理。
  • 直接对原始消息哈希签名:有些库(如 web3.eth.sign)会添加前缀,有些不会,务必确认使用的签名方法。
  • 未检查签名者地址为 0 :如果 ecrecover 失败返回 0,可能绕过检查。
  • 使用过时的签名库:应使用 OpenZeppelin 等经过审计的库。

总结

在 Solidity 中验证签名是构建可信链下交互的基础。通过 ecrecover 函数并正确构造消息哈希,可以实现安全的签名验证。推荐使用 OpenZeppelin 的 ECDSA 库简化操作并避免常见陷阱。同时,务必考虑重放攻击、签名可塑性等安全问题,确保合约的鲁棒性。

相关推荐
珠海西格12 小时前
聚焦痛点|分布式光伏消纳困境的三大表现及红区治理难点
服务器·网络·分布式·安全·区块链
木西3 天前
深度拆解 Web3 预测市场:基于 Solidity 0.8.24 与 UMA 乐观预言机的核心实现
web3·智能合约·solidity
木西11 天前
揭秘 Web3 隐私社交标杆:CocoCat 的核心架构与智能合约实现
web3·智能合约·solidity
木西12 天前
深度拆解 Grass 模式:基于 EIP-712 与 DePIN 架构的奖励分发系统实现
web3·智能合约·solidity
kida_yuan12 天前
【以太来袭】4. Geth 原理与解析
区块链
blockcoach14 天前
刘教链|金融市场中的物理学规律:平方根定律
区块链
碳链价值14 天前
吴忌寒清仓比特币背后
区块链
blockcoach14 天前
刘教链|BTC的时光机
区块链