Skip to content
On this page

L2-9: Sign + ecrecover(签名验证)

1. 问题

在以太坊上,链下签名 + 链上验证是实现"免 Gas 交易"和"元交易"的核心范式。用户用自己的私钥在链下对消息签名(零 Gas 成本),然后将签名提交到链上——任何人都可以代为执行交易并支付 Gas。但这里面藏着几个关键问题:签名格式的不同前缀标准(eth_sign 的 "\x19Ethereum Signed Message:\n32" vs EIP-712 的 "\x19\x01")决定了签名的安全域;ecrecover 预编译对畸形签名的处理方式可能导致签名地址为 address(0);v 值的不同编码方式(27/28 vs 0/1)在不同库之间存在兼容性问题。

本挑战(参考 src/level2/SignatureVerifier.sol)要求实现两种签名验证方式:标准的 eth_sign 验证和 EIP-712 Permit 验证——这是 EIP-2612 Permit 授权、Gasless 代币转账和所有元交易模式的密码学基础。

2. 原因

签名验证是以太坊密码学的三次飞跃:从"我会调 ethers.signMessage()"到"我理解 eth_sign 的前缀魔法",最后到"我能实现 EIP-712 类型化签名"。每一个阶段对应不同的安全等级。

eth_sign 的前缀 "\x19Ethereum Signed Message:\n32" 不是随机字符串——它是专门设计来防止签名的跨协议重放攻击。在比特币早期,有人发现一笔交易的签名可以被复制到另一笔完全不同的交易中仍然有效。以太坊通过在所有签名数据前强制添加前缀来解决这个问题——如果没有这个前缀,你为某个 DApp 签名的消息可能被重放到另一个 DApp 的签名验证逻辑中。

EIP-712 更进一步:它不仅加了前缀,还附加了"它在哪个合约(verifyingContract)的哪个链(chainId)上有效"的完整领域信息。这意味着即使两个 DApp 使用完全相同的 Permit 数据结构,签名也不会互相重放。"\x19\x01" 中的 0x01 是 EIP-712 的版本标识——如果未来 EIP-712 升级,这个字节可以改变,确保向后兼容。

理解 ecrecover 的工作方式是掌握底层密码学的关键。ecrecover(messageHash, v, r, s) 从 ECDSA 签名中恢复出签名者的公钥(进而得出地址)——它不"验证"签名,而是"恢复"签名。任何有效的 (v, r, s) 三元组都可以恢复出一个地址,但要确认这个地址就是预期的签名者,你需要在恢复后进行比较。

3. 方案

SignatureVerifier 合约(参考 src/level2/SignatureVerifier.sol)提供两种验证方法和完整的 EIP-712 基础设施:

方法 1:eth_sign 标准签名验证

solidity
function verifyEthSign(
    bytes32 message,
    uint8 v,
    bytes32 r,
    bytes32 s
) public pure returns (address signer) {
    // 构造以太坊签名消息前缀
    bytes32 ethSignedMessageHash = keccak256(
        abi.encodePacked("\x19Ethereum Signed Message:\n32", message)
    );
    // 从签名恢复地址
    signer = ecrecover(ethSignedMessageHash, v, r, s);
    if (signer == address(0)) revert InvalidSignature();
    return signer;
}

关键细节:

  • 前缀 "\x19Ethereum Signed Message:\n32"personal_signeth_sign 标准兼容
  • message 参数预期是 32 字节的哈希值
  • ecrecover 返回 address(0) 表示签名无效(如 v 值不在 [27, 28] 范围或 s 值过高)

方法 2:EIP-712 Permit 类型化签名验证

solidity
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public immutable PERMIT_TYPEHASH;

bytes32 private constant _PERMIT_TYPEHASH =
    keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

constructor() {
    DOMAIN_SEPARATOR = keccak256(abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
        keccak256(bytes("SignatureVerifier")),
        keccak256(bytes("1")),
        block.chainid,
        address(this)
    ));
    PERMIT_TYPEHASH = _PERMIT_TYPEHASH;
}

function verifyEIP712Permit(
    address owner, address spender, uint256 value,
    uint256 deadline, uint8 v, bytes32 r, bytes32 s
) public returns (bool) {
    if (block.timestamp > deadline) revert ExpiredPermit(deadline, block.timestamp);

    // 构造结构化数据哈希
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH, owner, spender, value,
        nonces[owner]++,   // 增量 nonce,防止重放
        deadline
    ));

    // 构造 EIP-712 领域分隔的最终摘要
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));

    address signer = ecrecover(digest, v, r, s);
    if (signer == address(0) || signer != owner) {
        revert WrongSigner(owner, signer);
    }

    emit PermitVerified(owner, spender, value);
    return true;
}

EIP-712 签名构造的层次结构

1. TYPEHASH  = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
2. structHash = keccak256(TYPEHASH || owner || spender || value || nonce || deadline)
3. DOMAIN_SEPARATOR = keccak256(EIP712Domain || name || version || chainId || verifyingContract)
4. digest = keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash)
5. ecrecover(digest, v, r, s) → signer

链下签名构造(JavaScript / ethers.js)

javascript
const domain = {
    name: 'SignatureVerifier',
    version: '1',
    chainId: (await provider.getNetwork()).chainId,
    verifyingContract: contractAddress,
};
const types = {
    Permit: [
        { name: 'owner', type: 'address' },
        { name: 'spender', type: 'address' },
        { name: 'value', type: 'uint256' },
        { name: 'nonce', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
    ],
};
const nonce = await contract.nonces(wallet.address);
const message = { owner: wallet.address, spender, value, nonce, deadline };
const signature = await wallet.signTypedData(domain, types, message);
const { v, r, s } = ethers.Signature.from(signature);

4. 遭遇的陷阱

  • ecrecover 返回 address(0):当 v 值不是 27 或 28(或对应的 0、1),或 s 值超过 secp256k1n / 2,ecrecover 返回 address(0) 而不是 revert——这在没有显式检查时会通过签名验证
  • 前缀混淆:混淆 "\x19Ethereum Signed Message:\n32"(eth_sign)和 "\x19\x01"(EIP-712)前缀,导致签名永远无法通过验证
  • 消息哈希长度不是 32 字节:eth_sign 的前缀 \n32 明确期望消息长度恰好为 32 字节,传入任意长度的原始消息会导致签名不匹配
  • nonce 管理不当:在 EIP-712 验证中忘记自增 nonce,或使用了错误的 nonce 顺序——导致签名重放攻击
  • v 值编码方式差异:ether.js 的 Signature.from() 返回的 v 是 27/28,而某些库返回 0/1——如果在链上硬编码检查 27/28,可能拒绝有效的签名
  • deadline 检查不严谨:使用 > 而非 >= 导致恰好在 deadline 时刻的合法签名被拒绝

5. 陷阱的原因

ecrecover 的沉默失败是其预编译实现的设计选择。EVM 预编译合约 0x01 (ecrecover) 在内部调用 secp256k1 椭圆曲线运算,当输入参数超出曲线群的合法范围时(如 v 不是 27/28、s 超出 n/2),它会返回空结果(64 个零字节),Solidity 将其解释为 address(0)。这个行为极为危险:如果你只检查 signer == expectedOwner,address(0) != expectedOwner 会正确失败。但如果你错误地检查 signer != address(0)(认为这样就够了),一个精心构造的无效签名就可以绕过去——不过实际上这种情况需要额外的编程错误组合。更常见的问题是只检查 signer != address(0) 但不比较 signer 和 expectedOwner。

前缀体系的存在是为了签名域分离(Domain Separation)。没有 "\x19" 前缀和特定的消息格式,一个签名为 A 合约签的 Permit 可以被直接复制到 B 合约中重放。EIP-712 的 "\x19\x01" 通过附加 DOMAIN_SEPARATOR(包含 chainId + verifyingContract)确保签名只在一个合约的一条链上有效,即使两个合约的 Permit 结构完全相同。

nonce 是防御签名重放的关键机制。如果没有 nonce(或者 nonce 不自增),用户签过一个 Permit 后,任何人都可以无限次地在链上提交相同的 (v, r, s) 三元组。nonce 自增使得每个签名只能使用一次。

6. 如何解决陷阱

始终同时检查 ecrecover 返回值的两个条件:不为 address(0) 且等于预期的签名者地址:

solidity
address signer = ecrecover(digest, v, r, s);
if (signer == address(0) || signer != owner) {
    revert WrongSigner(owner, signer);
}

在构造函数中正确构造 DOMAIN_SEPARATOR——使用 keccak256 哈希字符串名称和版本号,而不是直接将字符串作为 bytes 传入。这确保与 EIP-712 标准兼容(标准要求使用哈希后的值):

solidity
DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256(bytes("SignatureVerifier")),  // 哈希名称
    keccak256(bytes("1")),                  // 哈希版本
    block.chainid,
    address(this)
));

对于 nonce 管理,使用 nonces[owner]++ 在验证时原地自增——这确保每次验证使用不一样的 nonce,任何重放尝试都会因为 nonce 不匹配而失败。提供 getNonce() 查询函数让前端能获取当前的 nonce 值。

对于 deadline 检查,使用严格大于 > 而非 >=,允许恰好在 deadline 区块的签名通过。同时提供有意义的错误信息,告知用户当前时间与 deadline 的差距。

solidity
if (block.timestamp > deadline) revert ExpiredPermit(deadline, block.timestamp);

7. 技术要点

要点说明
eth_sign 前缀"\x19Ethereum Signed Message:\n32" + keccak256(message)
EIP-712 前缀"\x19\x01" + DOMAIN_SEPARATOR + structHash
DOMAIN_SEPARATORkeccak256(name, version, chainId, verifyingContract)
ecrecover 失败返回address(0)(不 revert,必须手动检查)
v 值范围27/28(EIP-155 风格)或 0/1,ecrecover 兼容两者
nonce 防重放每次验证自增 nonces[owner]++,确保签名不可重复使用
类型哈希keccak256("Permit(address owner,...)") — 结构定义的标准化表示
structHash`keccak256(TYPEHASH

Built with AiAda