EIP-712签名验证实战:智能合约中实现安全以太坊签名

EIP-712签名验证安全场景,展示以太坊智能合约加密认证

Web3应用中,让用户签署消息来授权操作是常见需求。相比每次操作都发送交易,签名可以大幅降低成本,而且用户体验也更好。但传统的eth_sign方法缺乏结构化,用户很难理解自己到底在签什么。EIP-712就是为了解决这个问题而诞生的。

为什么需要EIP-712

在没有EIP-712之前,以太坊签名是纯粹的字节数据。用户看到的可能是一串十六进制字符串,根本不知道自己签的内容是什么。这带来两个严重问题:

  1. 签名欺骗 – 恶意网站可以构造看起来无害的消息,实际包含恶意操作
  2. 用户体验差 – 用户无法验证消息内容,信任成本高

EIP-712引入了类型化结构化数据的概念,让签名内容和签名请求都可以被人类阅读和理解。这不仅提升了安全性,也大大改善了用户体验。

EIP-712结构化签名流程图,从域分隔符到签名验证的完整路径

EIP-712的核心概念

理解EIP-712需要掌握几个核心概念:

Domain Separator

域分隔符定义了签名的上下文,防止跨应用重放攻击。它包含:

  • name – 应用名称
  • version – 签名方案的版本号
  • chainId – 链ID,防止跨链攻击
  • verifyingContract – 验证签名的合约地址
  • salt – 可选的随机盐值

solidity

struct EIP712Domain {
    string name;
    string version;
    uint256 chainId;
    address verifyingContract;
    bytes32 salt;
}

Message Structure

实际签名的消息是定义好的结构体,每种操作类型对应一个独立的结构:

solidity

struct Person {
    string name;
    address wallet;
}

struct Mail {
    Person from;
    Person to;
    string contents;
}

contract EIP712Example {
    bytes32 constant MAIL_TYPEHASH = keccak256(
        "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
    );
    
    bytes32 constant PERSON_TYPEHASH = keccak256(
        "Person(string name,address wallet)"
    );
    
    bytes32 public domainSeparator;
    
    constructor() {
        domainSeparator = keccak256(abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)"),
            keccak256("Mail"),
            keccak256("1"),
            block.chainid,
            address(this),
            bytes32(0) // salt,可以自定义
        ));
    }
}

哈希计算流程

签名的完整哈希计算遵循特定的层次结构:

plaintext

hashStruct(message) = keccak256(typeHash ‖ data)

hash(message) = keccak256(0x1901 ‖ domainSeparator ‖ hashStruct(message))

0x1901是固定的magic bytes,标识这是EIP-712签名。

完整签名验证合约实现

下面是一个完整的EIP-712签名验证合约,支持委托授权模式:

solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

library ECDSA {
    function recover(bytes32 hash, bytes memory signature)
        internal
        pure
        returns (address)
    {
        require(signature.length == 65, "Invalid signature length");
        
        bytes32 r;
        bytes32 s;
        uint8 v;
        
        assembly {
            r := mload(add(signature, 32))
            s := mload(add(signature, 64))
            v := byte(0, mload(add(signature, 96)))
        }
        
        return ecrecover(hash, v, r, s);
    }
    
    function toEthSignedMessageHash(bytes32 hash)
        internal
        pure
        returns (bytes32)
    {
        return keccak256(abi.encodePacked(
            "\x19Ethereum Signed Message:\n32",
            hash
        ));
    }
}

contract SecureDelegate {
    using ECDSA for bytes32;
    
    // 委托授权结构
    struct Delegation {
        address delegate;      // 被委托的地址
        uint256 nonce;         // 防重放 nonce
        uint256 deadline;      // 过期时间
    }
    
    // 委托类型哈希
    bytes32 constant DELEGATION_TYPEHASH = keccak256(
        "Delegation(address delegate,uint256 nonce,uint256 deadline)"
    );
    
    // 域名分隔符类型哈希
    bytes32 constant DOMAIN_TYPEHASH = keccak256(
        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
    );
    
    // 域名配置
    string public name;
    string public version = "1";
    
    // 委托者的nonce
    mapping(address => uint256) public nonces;
    
    // 已使用的签名哈希(用于一次性签名)
    mapping(bytes32 => bool) public usedSignatures;
    
    event DelegateUpdated(address indexed delegator, address indexed newDelegate);
    event DelegationExecuted(
        address indexed delegator,
        address indexed delegate,
        bytes32 indexed delegationHash
    );
    
    constructor(string memory _name) {
        name = _name;
    }
    
    function getDomainSeparator() public view returns (bytes32) {
        return keccak256(abi.encode(
            DOMAIN_TYPEHASH,
            keccak256(bytes(name)),
            keccak256(bytes(version)),
            block.chainid,
            address(this)
        ));
    }
    
    function _hashDelegation(Delegation memory delegation)
        internal
        view
        returns (bytes32)
    {
        return keccak256(abi.encode(
            DELEGATION_TYPEHASH,
            delegation.delegate,
            delegation.nonce,
            delegation.deadline
        ));
    }
    
    function _hashTypedData(Delegation memory delegation)
        internal
        view
        returns (bytes32)
    {
        bytes32 hash = _hashDelegation(delegation);
        return keccak256(abi.encodePacked(
            "\x19\x01",
            getDomainSeparator(),
            hash
        ));
    }
    
    /**
     * @dev 验证签名并返回签名者地址
     */
    function verify(
        Delegation memory delegation,
        bytes memory signature
    ) public view returns (address) {
        require(delegation.deadline >= block.timestamp, "Delegation expired");
        
        bytes32 typedHash = _hashTypedData(delegation);
        address signer = typedHash.recover(signature);
        
        require(signer != address(0), "Invalid signature");
        require(signer == msg.sender || isAuthorizedDelegate(signer), "Not authorized");
        
        return signer;
    }
    
    /**
     * @dev 检查签名是否已使用(一次性签名模式)
     */
    modifier notUsed(bytes32 hash) {
        require(!usedSignatures[hash], "Signature already used");
        _;
    }
    
    /**
     * @dev 执行委托操作(一次性签名模式)
     */
    function executeDelegation(
        Delegation memory delegation,
        bytes memory signature
    ) external notUsed(_hashTypedData(delegation)) returns (bool) {
        bytes32 typedHash = _hashTypedData(delegation);
        
        // 验证签名
        address signer = typedHash.recover(signature);
        require(signer != address(0), "Invalid signature");
        require(delegation.deadline >= block.timestamp, "Delegation expired");
        
        // 标记签名已使用
        usedSignatures[typedHash] = true;
        
        // 更新nonce
        nonces[signer]++;
        
        emit DelegationExecuted(signer, delegation.delegate, typedHash);
        
        return true;
    }
    
    /**
     * @dev 批量执行多个委托
     */
    function executeBatch(
        Delegation[] memory delegations,
        bytes[] memory signatures
    ) external returns (bool[] memory results) {
        require(delegations.length == signatures.length, "Length mismatch");
        
        results = new bool[](delegations.length);
        
        for (uint256 i = 0; i < delegations.length; i++) {
            bytes32 typedHash = _hashTypedData(delegations[i]);
            
            if (usedSignatures[typedHash]) {
                results[i] = false;
                continue;
            }
            
            address signer = typedHash.recover(signatures[i]);
            
            if (signer == address(0) || delegations[i].deadline < block.timestamp) {
                results[i] = false;
                continue;
            }
            
            usedSignatures[typedHash] = true;
            nonces[signer]++;
            
            emit DelegationExecuted(signer, delegations[i].delegate, typedHash);
            results[i] = true;
        }
    }
    
    /**
     * @dev 检查地址是否为授权委托者
     */
    function isAuthorizedDelegate(address) public pure returns (bool) {
        // 可以扩展此逻辑,实现更复杂的授权机制
        return true;
    }
    
    /**
     * @dev 获取当前nonce
     */
    function getNonce(address account) public view returns (uint256) {
        return nonces[account];
    }
}

这个合约展示了EIP-712签名的核心验证逻辑,包括域分隔符计算、类型哈希、签名恢复等关键步骤。

前端签名生成

合约端的验证需要前端生成对应的签名。以下是使用ethers.js v6生成签名的示例:

javascript

import { ethers } from "ethers";

const DOMAIN = {
  name: "SecureDelegate",
  version: "1",
  chainId: 1, // 替换为实际链ID
  verifyingContract: "0x1234567890123456789012345678901234567890", // 替换为合约地址
};

const DELEGATION_TYPE = {
  Delegation: [
    { name: "delegate", type: "address" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

async function signDelegation(delegator, delegateAddress, deadline, nonce) {
  const provider = new ethers.BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();
  
  const message = {
    delegate: delegateAddress,
    nonce: nonce,
    deadline: deadline,
  };
  
  const signature = await signer.signTypedData(
    DOMAIN,
    DELEGATION_TYPE,
    message
  );
  
  return signature;
}

// 使用示例
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时后过期
const nonce = await contract.nonces(userAddress);
const signature = await signDelegation(userAddress, delegateAddress, deadline, nonce);

ethers.js会自动处理类型化数据的哈希计算和签名,大大简化了EIP-712的实现复杂度。

安全性注意事项

EIP-712签名虽然比裸字节签名安全得多,但在实现时仍需注意以下几点:

1. 防止签名重放

每次签名应该包含唯一的nonce和合理的过期时间:

solidity

function verifyWithReplayProtection(
    Delegation memory delegation,
    bytes memory signature
) public view returns (address) {
    // 检查nonce是否匹配
    require(delegation.nonce == nonces[msg.sender], "Invalid nonce");
    
    // 检查过期时间
    require(delegation.deadline >= block.timestamp, "Signature expired");
    
    // ... 其他验证逻辑
}

2. 验证签名者身份

签名恢复可能返回address(0),这是判断签名无效的关键条件:

solidity

address signer = ECDSA.recover(hash, signature);
require(signer != address(0), "ECDSA: invalid signature");
// 不要忘记检查 signer 是否在预期的身份范围内
require(signer == expectedAddress, "Wrong signer");

3. 完整的域分隔符

域名分隔符的每个字段都应该正确设置,特别是chainId:

solidity

// 错误的做法:在测试网上使用硬编码的chainId
bytes32 domainSeparator = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256("MyApp"),
    keccak256("1"),
    1, // 硬编码的mainnet chainId
    address(this)
));

// 正确的做法:使用当前链的chainId
bytes32 domainSeparator = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256("MyApp"),
    keccak256("1"),
    block.chainid, // 使用动态chainId
    address(this)
));

4. 一次性签名 vs 可重用授权

根据业务场景选择合适的签名模式:

solidity

// 一次性签名:每次操作都需要新签名
mapping(bytes32 => bool) public usedSignatures;
function executeOnce(bytes32 hash, bytes memory signature) {
    require(!usedSignatures[hash], "Already used");
    usedSignatures[hash] = true;
    // 执行操作
}

// 可重用授权:设置一个较长的有效期
mapping(address => uint256) public authorizedUntil;
function executeWithAuth(address signer) {
    require(authorizedUntil[signer] >= block.timestamp, "Authorization expired");
    // 执行操作
}

实际应用场景

EIP-712签名在以下场景特别有用:

  1. DAO治理投票 – 离线签名投票,减少投票者的Gas成本
  2. DEX限价单 – 用户签名挂单,匹配引擎执行,无需每次都发交易
  3. 许可/白名单 – 用户签名加入白名单的操作证明
  4. 跨链消息 – 在一条链上签名,在另一条链上验证

solidity

contract SimpleOffchainVoting {
    struct Vote {
        uint256 proposalId;
        bool support;
        uint256 deadline;
    }
    
    bytes32 constant VOTE_TYPEHASH = keccak256(
        "Vote(uint256 proposalId,bool support,uint256 deadline)"
    );
    
    mapping(uint256 => mapping(address => bool)) public hasVoted;
    mapping(uint256 => uint256) public votesFor;
    mapping(uint256 => uint256) public votesAgainst;
    
    function castVote(Vote memory vote, bytes memory signature) public {
        bytes32 voteHash = keccak256(abi.encode(
            VOTE_TYPEHASH,
            vote.proposalId,
            vote.support,
            vote.deadline
        ));
        
        // 验证签名
        address voter = ECDSA.recover(
            ECDSA.toEthSignedMessageHash(voteHash),
            signature
        );
        
        require(voter == msg.sender, "Signature mismatch");
        require(!hasVoted[vote.proposalId][voter], "Already voted");
        require(vote.deadline >= block.timestamp, "Vote expired");
        
        // 记录投票
        hasVoted[vote.proposalId][voter] = true;
        if (vote.support) {
            votesFor[vote.proposalId]++;
        } else {
            votesAgainst[vote.proposalId]++;
        }
    }
}

常见错误与调试

类型字符串不匹配

前端和合约端的类型定义必须完全一致:

javascript

// 前端 - 错误的顺序
const TYPE = {
  Person: [
    { name: "wallet", type: "address" }, // 顺序错误
    { name: "name", type: "string" },
  ]
};

// 合约端
bytes32 constant PERSON_TYPEHASH = keccak256(
    "Person(address wallet,string name)" // 必须匹配
);

缺失链ID验证

生产环境中务必验证chainId:

solidity

require(
    keccak256(abi.encodePacked(DOMAIN.chainId)) == keccak256(abi.encodePacked(block.chainid)),
    "Chain ID mismatch"
);

签名版本不兼容

ethers.js v5和v6的API有所不同:

javascript

// ethers.js v6
const signature = await signer.signTypedData(domain, types, message);

// ethers.js v5
const signature = await provider.getSigner()._signTypedData(domain, types, message);

总结

EIP-712为以太坊签名带来了结构化和可读性,是构建安全DApp的重要工具。实现时需要关注:

  • 完整的域名分隔符 – 包含name、version、chainId、verifyingContract
  • 正确的类型哈希 – 前后端类型定义必须一致
  • 防重放机制 – nonce和deadline是必需的
  • 签名者验证 – 验证恢复地址的有效性

通过合理使用EIP-712,可以显著提升应用的安全性和用户体验,同时降低用户的Gas成本。这在高频操作场景(如DAO投票、DEX交易)中尤为重要。

相关资源

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注