理解 EIP-712 和 EIP-191:以太坊签名标准指南

  • cyfrin
  • 发布于 2025-02-24 20:46
  • 阅读 28

本文介绍了以太坊的签名标准 EIP-191 和 EIP-712,详细说明了这两项标准的背景、实现方式以及如何通过结构化数据防止重放攻击。文章提供了多个示例代码,讲解了签名的创建、验证过程及必要的安全措施。

EIP-712 和 EIP-191 | 理解以太坊签名标准

了解你需要知道的一切关于以太坊改进提案(Ethereum Improvement Proposal)EIP-191、EIP-712 和以太坊签名标准。

要理解签名创建、验证和防止重放攻击的工作原理,首先需要理解以太坊改进提案 EIP-191 和 EIP-712。

在签署交易时,需要一种更简单的方式来读取交易数据。例如,在这些标准创建之前,在 MetaMask 中签署交易时显示的消息如下:

显示在实现 eip712 之前的 metamask 签名消息

图像:在 MetaMask 中签署交易时未结构化的消息

这些标准意味着交易可以以可读的方式显示:

显示在实施 eip712 后的 metamask 交易数据

图像:在 MetaMask 中使用 EIP-712 签署的结构化消息

此外,EIP-712 是防止重放攻击的关键——防止重放攻击的数据编码在结构化数据中。

本文概述了这些标准、它们的动机以及如何实现它们。

— 本文的完整源代码由 Patrick Collins 编写,可以在 GitHub 上查看。

理解 EIP-712 和 EIP-191 的先决条件

推荐阅读这篇 ECDSA 签名文章 来理解前两个概念的基础。

— 请注意,本文中的代码用于演示目的,未经彻底的安全审查,请勿将其用作生产代码。

简单签名

对于简单签名,将验证函数实现到智能合约中涉及创建一个函数 getSimpleSigner(),该函数接受要签名的消息(可以是任何数据)和签名的 (r, s, v) 组件,该函数对消息进行哈希处理并使用预编译 ecrecover 检索签名者,并返回结果:

function getSignerSimple(uint256 message, uint8 _v, bytes32 _r, bytes32 _s) public pure returns (address) {
    // 哈希消息以进行签名
    bytes32 hashedMessage = bytes32(message); // 如果是字符串,使用 keccak256(abi.encodePacked(string))
    // 检索签名者
    address signer = ecrecover(hashedMessage, _v, _r, _s);
    return signer;
}

ecrecover 是一个 预编译,是内置于以太坊协议中的函数,使用 签名的 (r, s, v) 组件 从任何消息中检索签名者。

然后,函数 verifySignerSimple() 将检索到的签名者与期望的签名者进行比较,如果结果不是预期的签名者,则会回退:

function verifySignerSimple(
    uint256 message,
    uint8 _v,
    bytes32 _r,
    bytes32 _s,
    address signer
)
    public
    pure
    returns (bool)
{
    address actualSigner = getSignerSimple(message, _v, _r, _s);
    require(signer == actualSigner);
    return true;
}

这就是签名在基本层面上的工作原理:获取一些哈希消息加上消息的签名,检索签名者,并检查它是否是预期的地址。

但存在一个问题,需要有一种方式使用预制签名发送交易:赞助交易。这在智能合约外部已经可以做到,但需要有一种方式将其构建到智能合约的函数中。例如,Bob 签署一条消息(一个交易),并将签名传递给 Alice。Alice 使用这个签名发送交易,这意味着 Bob 可以为她支付 gas 费用。因此,EIP-191 被引入。

以太坊改进提案 - EIP-191:标准化签名

EIP-191 签名数据标准 提出了签名数据的以下格式:0x19 <1 byte version> <version specific data> <data to sign>

  • 0x19: 前缀。
  • 表示数据是签名。
  • 其十进制值为 25,选择 0x19 是因为在其他上下文中没有使用。
  • 这还确保与签名消息关联的数据不能是有效的 ETH 交易,因为 ETH 交易是如何编码的。
  • <1 byte version>: “签名数据”的版本 使用。
  • 允许不同版本具有不同的签名数据结构。
  • 值:
  • 0x00: 带有预期验证器的数据。
  • 0x01: 结构化数据 - 最常用于生产应用并与 EIP-712 相关,在下一个部分讨论。
  • 0x02: personal_sign 消息。
  • <data to sign>: 旨在签名的消息。

以下 getSigner191() 函数演示了如何设置 EIP-191 签名:

function getSigner191(uint256 message, uint8 _v, bytes32 _r, bytes32 _s) public view returns (address) {
        // 计算哈希以进行验证时的参数
        // 1: byte(0x19) - 初始的 0x19 字节
        // 2: byte(0) - 版本字节
        // 3: 特定于版本的数据,对于版本 0,意图验证器地址
        // 4-6:特定于应用的数据

        bytes1 prefix = bytes1(0x19);
        bytes1 eip191Version = bytes1(0);
        address indendedValidatorAddress = address(this);
        bytes32 applicationSpecificData = bytes32(message);

        // 0x19  < 1 byte version>  < version specific data>  < data to sign>
        bytes32 hashedMessage =
            keccak256(abi.encodePacked(prefix, eip191Version, indendedValidatorAddress, applicationSpecificData));

        address signer = ecrecover(hashedMessage, _v, _r, _s);
        return signer;
    }

可以看出,使用该标准检索签名者的过程更为冗长。

然后,可以像之前一样将签名者与预期的签名者进行比较:

function verifySigner191(
    uint256 message,
    uint8 _v,
    bytes32 _r,
    bytes32 _s,
    address signer
)
    public
    view
    returns (bool)
{
    address actualSigner = getSigner191(message, _v, _r, _s);
    require(signer == actualSigner);
    return true;
}

但是,如果 <data to sign> 更复杂呢?需要有一种方式格式化数据,使其更容易理解。因此,数据格式需要标准化,EIP-712 被引入。

以太坊改进提案 - EIP-712:使签名易于阅读

EIP-191 不够具体,应用(版本)特定的数据需要标准化。这意味着可以更容易阅读签名并显示在钱包内部,例如 MetaMask,并防止 重放攻击

EIP-712 引入了标准化数据:类型结构化数据哈希和签名。

签名现在具有以下结构:

0x19 0x01 <domainSeparator> <hashStruct(message)>

  • 0x19: 前缀(以前的)。
  • 0x01: 版本。
  • <domainSeparator>: 这是与版本相关的数据。
  • 域分隔符是定义正在签名消息域的结构的哈希。
  • 结构包含以下所有内容之一,并称为 eip712Domain
struct EIP712Domain {
    string name;
    string version;
    uint256 chainId;
    address verifyingContract;
    // bytes32 salt; 不必需
}

这意味着合约可以知道签名是专门为他们创建的还是不是。了解这一点后,EIP-712 数据可以重写为:

0x19 0x01 <hashStruct(eip712Domain)> <hashStruct(message)>

但什么是哈希结构?

哈希结构的符号定义为

hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))

其中 typeHash = keccak256(encodeType(typeOf(s)))

哈希结构是一个结构的哈希,包括:

结构看起来的哈希 - typehash。对于 <domainSeparator>,typehash 是:

// EIP721 域结构的哈希
bytes32 constant EIP712DOMAIN_TYPEHASH =
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

数据的哈希。对于域分隔符,该数据是 eip721Domain 结构数据:

// 定义“域”结构的样子。
EIP712Domain eip_712_domain_separator_struct = EIP712Domain({
    name: "SignatureVerifier", // 这可以是任何内容
    version: "1", // 这可以是任何内容
    chainId: 1, // 理想情况下是链 ID
    verifyingContract: address(this) // 理想情况下,将其设置为“this”,但可以是任何合约来验证签名
});

将这些内容组合在一起,<domainSeparator> 变为:

// 现在已知签名的格式,定义谁将验证签名。
bytes32 public immutable i_domain_separator = keccak256(
    abi.encode(
        EIP712DOMAIN_TYPEHASH,
        keccak256(bytes(eip_712_domain_separator_struct.name)),
        keccak256(bytes(eip_712_domain_separator_struct.version)),
        eip_712_domain_separator_struct.chainId,
        eip_712_domain_separator_struct.verifyingContract
    )
);
  • <hashStruct(message)>: 要签名的消息的哈希结构。

使用哈希结构的先前定义,定义 typehash:

// 定义消息哈希结构的样子。
struct Message {
    uint256 number;
}

bytes32 public constant MESSAGE_TYPEHASH = keccak256("Message(uint256 number)");

然后,<hashStruct(message)> 变为:

bytes32 hashedMessage = keccak256(abi.encode(MESSAGE_TYPEHASH, Message({ number: message })));

EIP-712 数据可以被认为是:

0x19 0x01 <验证此签名的对象及其类型的哈希> <签署结构化消息及其类型的哈希>

将所有这些组合在一起,获取签名者的函数变为:

function getSignerEIP712(uint256 message, uint8 _v, bytes32 _r, bytes32 _s) public view returns (address) {
    // 计算哈希以进行验证时的参数
    // 1: byte(0x19) - 初始的 0x19 字节
    // 2: byte(1) - 版本字节
    // 3: 域分隔符的哈希结构(包含域结构的 typehash)
    // 4: 消息的哈希结构(包含消息结构的 typehash)

    bytes1 prefix = bytes1(0x19);
    bytes1 eip712Version = bytes1(0x01); // EIP-712 是 EIP-191 的版本 1
    bytes32 hashStructOfDomainSeparator = i_domain_separator;

    // 哈希消息结构
    bytes32 hashedMessage = keccak256(abi.encode(MESSAGE_TYPEHASH, Message({ number: message })));

    // 最后,组合它们
    bytes32 digest = keccak256(abi.encodePacked(prefix, eip712Version, hashStructOfDomainSeparator, hashedMessage));
    return ecrecover(digest, _v, _r, _s);
}

然后,可以通过将其与预期签名者进行比较来验证签名者,如之前所述:

function verifySigner712(
    uint256 message,
    uint8 _v,
    bytes32 _r,
    bytes32 _s,
    address signer
)
    public
    view
    returns (bool)
{
    address actualSigner = getSignerEIP712(message, _v, _r, _s);

    require(signer == actualSigner);
    return true;
}

签名重放攻击预防

如前所述,EIP-712 是防止 重放攻击 的关键。

理解 EIP-191 和 EIP-712 对于理解如何创建防重放的签名数据至关重要。EIP-712 结构中的额外数据确保了重放防护。

为了防止重放攻击,智能合约必须:

  1. 每个签名都必须有一个唯一的随机数,并进行验证
  2. 设置并检查过期日期
  3. s 值限制为单个半部分
  4. 包含链 ID 以防止跨链重放攻击
  5. 任何其他唯一标识符(例如,如果在同一合约/链/etc 中有多个对象要签名)

— 欲了解更多有关签名重放攻击及如何防止它们的信息,请参阅 这份综合指南

总结

在关于 EIP-191 和 EIP-712 的本指南中,你已经了解了以太坊签名标准的工作原理。总结如下:

  • EIP-191:标准化签名数据的格式。
  • EIP-712:标准化特定版本的数据格式和待签名的数据。

为了充分理解签名的创建、验证和重放,理解这两个标准至关重要。这种理解是编写安全智能合约的关键。

  • 原文链接: cyfrin.io/blog/understan...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.