Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7739: 智能账户的可读类型化签名

一种防御性的重新哈希方案,可防止智能账户之间的签名重放,并保留签名内容的可读性

Authors vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Hadrien Croubois (@Amxx), Ernesto García (@ernestognw), Im Juno (@junomonster), howydev (@howydev), Atarpara (@Atarpara), 0xcuriousapple (@0xcuriousapple)
Created 2024-05-28
Discussion Link https://ethereum-magicians.org/t/erc-7739-readable-typed-signatures-for-smart-accounts/20513
Requires EIP-191, EIP-712, EIP-1271, EIP-5267

摘要

本提案定义了一个标准,用于防止当多个智能账户由单个外部拥有账户 (EOA) 拥有时,签名在这些账户之间的重放。这是通过针对 ERC-1271 验证的防御性重新哈希方案实现的,该方案使用特定的嵌套 EIP-712 类型化结构,从而在钱包客户端签名请求期间保留签名内容的可读性。

动机

智能账户可以使用 ERC-1271 通过 isValidSignature 函数验证签名。

如下所示的直接实现容易受到签名重放攻击。

/// @dev 此实现不安全。
function isValidSignature(
    bytes32 hash,
    bytes calldata signature
) external override view returns (bytes4) {
    uint8 v = uint8(signature[64]);
    (bytes32 r, bytes32 s) = abi.decode(signature, (bytes32, bytes32));
    // 拒绝可延展的签名。
    require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0);
    address signer = ecrecover(hash, v, r, s);
    // 拒绝失败的恢复。
    require(signer != address(0));
    // `owner` 是一个存储变量,包含智能账户的所有者。
    if (signer == owner) {
        return 0x1626ba7e;
    } else {
        return 0xffffffff;
    }
}

当多个智能账户由单个 EOA 拥有时,如果 hash 不包括智能账户地址,则相同的签名可以在智能账户之间重放。

不幸的是,许多流行的应用程序(例如 Permit2)都是这种情况。因此,许多智能账户实现都执行某种形式的防御性重新哈希。首先,智能账户从以下内容中计算出一个最终哈希:(1)哈希,(2)其自身的地址,(3)链 ID。然后,智能账户根据签名验证最终哈希。可以使用 EIP-712 实现防御性重新哈希,但直接的实现会使签名内容不透明。

此标准提供了一种防御性重新哈希方案,该方案使签名内容在所有支持 EIP-712 的钱包客户端中可见。它旨在最大限度地减少采用摩擦。即使钱包客户端或应用程序前端未更新,用户仍然可以注入客户端 JavaScript 来启用防御性重新哈希。

规范

本文档中的关键词“必须 (MUST)”,“不得 (MUST NOT)”,“必需 (REQUIRED)”,“应 (SHALL)”,“不应 (SHALL NOT)”,“应该 (SHOULD)”,“不应该 (SHOULD NOT)”,“推荐 (RECOMMENDED)”,“不推荐 (NOT RECOMMENDED)”,“可以 (MAY)”,和“可选 (OPTIONAL)”按照 RFC 2119RFC 8174 中的描述进行解释。

概述

以下依赖是必需的 (REQUIRED):

  • EIP-712 类型化结构化数据哈希和签名。
    在内部提供相关的类型化数据哈希逻辑,这是构造最终哈希所必需的。

  • ERC-1271 合约的标准签名验证方法。
    提供 isValidSignature(bytes32 hash, bytes calldata signature) 函数。

  • ERC-5267 EIP-712 域的检索。
    提供 eip712Domain() 函数,该函数是计算最终哈希所必需的。

本标准定义了 ERC-1271isValidSignature 函数的行为,该函数包括两个工作流程:(1)TypedDataSign 工作流程,(2)PersonalSign 工作流程。

TypedDataSign 工作流程

TypedDataSign 工作流程处理 hash 最初使用 EIP-712 计算的情况。

TypedDataSign 最终哈希

TypedDataSign 工作流程的最终哈希定义为:

keccak256(\x19\x01 ‖ APP_DOMAIN_SEPARATOR ‖
    hashStruct(TypedDataSign({
        contents: hashStruct(originalStruct),
        name: eip712Domain().name,
        version: eip712Domain().version,
        chainId: eip712Domain().chainId,
        verifyingContract: eip712Domain().verifyingContract,
        salt: eip712Domain().salt
    }))
)

其中 表示字节的连接运算符。

在 Solidity 中,这可以写成:

finalTypedDataSignHash = 
    keccak256(
        abi.encodePacked(
            hex"1901",
            // 应用程序特定的域分隔符。通过 `signature` 传递。
            bytes32(APP_DOMAIN_SEPARATOR),
            keccak256(
                abi.encode(
                    // 使用 `contentsType` 动态计算,该 `contentsType` 通过 `signature` 传递。
                    typedDataSignTypehash, 
                    // 这是 `contents` 结构哈希,通过 `signature` 传递。
                    bytes32(hashStruct(originalStruct)),
                    // `eip712Domain()` 来自 ERC-5267。
                    keccak256(bytes(eip712Domain().name)), 
                    keccak256(bytes(eip712Domain().version)),
                    uint256(eip712Domain().chainId),
                    uint256(uint160(eip712Domain().verifyingContract)),
                    bytes32(eip712Domain().salt)
                )
            )
        )
    );

其中 typedDataSignTypehash 是:

typedDataSignTypehash = 
    keccak256(
          abi.encodePacked(
              "TypedDataSign(",
                  contentsName, " contents,",
                  "string name,",
                  "string version,",
                  "uint256 chainId,",
                  "address verifyingContract,",
                  "bytes32 salt"
              ")",
              contentsType
          )
      );

如果 contentsType"Mail(address from,address to,string message)",则 contentsName 将是 "Mail"

contentsNamecontentsType 的子字符串,直到(不包括)第一个 "(" 实例:

在 Solidity 中,这可以写成:

// `slice(string memory subject, uint256 start, uint256 end)`
// 返回从 `start` 到 `end`(不包括)切片的 `subject` 的副本。
// `start` 和 `end` 是字节偏移量。
//
// `indexOf(string memory subject, string memory search)`
// 返回从左到右在 `subject` 中第一次出现 `search` 的字节索引,
// 如果未找到 `search`,则返回 `2**256 - 1`。
contentsName = 
    LibString.slice(
        contentsType,
        0, // 起始字节索引。
        LibString.indexOf(contentsType, "(") // 结束字节索引(不包括)。
    );

为了完整起见,提供了 LibString Solidity 库的副本

为了安全起见,如果以下任何一项为真,建议 (RECOMMENDED) 将签名视为无效:

  • contentsName 是空字符串(即 bytes(contentsName).length == 0)。
  • contentsName 以以下任何字节开头 abcdefghijklmnopqrstuvwxyz(
  • contentsName 包含以下任何字节 , )\x00

TypedDataSign 签名

传递到 isValidSignature 中的 signature 将更改为:

originalSignature ‖ APP_DOMAIN_SEPARATOR ‖ contents ‖ contentsDescription ‖ uint16(contentsDescription.length)

其中:

  • contents 是原始结构的 bytes32 结构哈希。
  • contentsDescription 是:
    • contentsType(隐式模式),
      其中 contentsTypecontentsName 开头。
    • contentsType ‖ contentsName(显式模式),
      其中 contentsType 不一定以 contentsName 开头。

在 Solidity 中,这可以写成:

signature = 
    abi.encodePacked(
        bytes(originalSignature),
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents),
        bytes(contentsDescription),
        uint16(contentsDescription.length)
    );

附加的 APP_DOMAIN_SEPARATORcontents 结构哈希将用于验证通过 isValidSignature 传入的 hash 是否确实正确,通过:

hash == keccak256(
    abi.encodePacked(
        hex"1901",
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents)
    )
)

如果 hash 与重建的哈希不匹配,则 hashsignatureTypedDataSign 工作流程下无效。

PersonalSign 工作流程

PersonalSign 工作流程处理 hash 最初使用 EIP-191 计算的情况。

PersonalSign 最终哈希

PersonalSign 工作流程的最终哈希定义为:

keccak256(\x19\x01 ‖ ACCOUNT_DOMAIN_SEPARATOR ‖
    hashStruct(PersonalSign({
        prefixed: keccak256(bytes(\x19Ethereum Signed Message:\n ‖
        base10(bytes(someString).length) ‖ someString))
    }))
)

其中 表示字节的连接运算符。

在 Solidity 中,这可以写成:

finalPersonalSignHash = 
    keccak256(
        abi.encodePacked(
            hex"1901",
            // 智能账户域分隔符。
            // 可以通过来自 ERC-5267 的 `eip712Domain()` 计算。
            bytes32(ACCOUNT_DOMAIN_SEPARATOR),
            keccak256(
                abi.encode(
                    // `PERSONAL_SIGN_TYPEHASH`。
                    keccak256("PersonalSign(bytes prefixed)"),
                    // `hash` 来自 `isValidSignature(hash, signature)`
                    hash
                )
            )
        )
    );

这里,hash 在应用程序合约中计算并传递到 isValidSignature 中。

智能账户不需要知道如何计算 hash。为了完整起见,这是它的计算方式:

hash =
    abi.encodePacked(
        "\x19Ethereum Signed Message:\n",
        // `toString` 返回 uint256 的 base10 表示形式。
        LibString.toString(someString.length),
        // 这是要签名的原始消息。
        someString
    );

PersonalSign 签名

PersonalSign 工作流程不需要将其他数据附加到传递到 isValidSignature 中的 signature

支持检测

智能账户应该 (SHOULD) 为 isValidSignature(0x7739773977397739773977397739773977397739773977397739773977397739, "") 返回 bytes4(0x77390001),以指示支持此标准。

如果此标准得到更新,则幻数 bytes4(0x77390001) 可以 (MAY) 递增。

签名验证工作流程推导

由于 isValidSignature 签名函数签名未更改,因此实现必须能够推导出验证签名所需的工作流程类型。

如果签名包含正确的数据以重建 hash,则 isValidSignature 函数必须执行 TypedDataSign 工作流程。 否则,isValidSignature 函数必须执行 PersonalSign 工作流程。

在 Solidity 中,可以这样编写检查:

// 如果这是真的,则表示 `signature` 包含
// 正确的 `APP_DOMAIN_SEPARATOR` 和 `contents`,
// 并且必须执行 `TypedDataSign` 工作流程。
// 否则,必须执行 `PersonalSign` 工作流程。
hash == keccak256(
    abi.encodePacked(
        hex"1901",
        bytes32(APP_DOMAIN_SEPARATOR),
        bytes32(contents)
    )
)

有条件地跳过防御性重新哈希

如果以下任何一项为真,则智能账户可以 (MAY) 跳过防御性重新哈希工作流程:

  • isValidSignature 被链下调用。
  • 传递到 isValidSignature 中的 hash 已经包含了智能账户的地址。

由于许多开发人员可能不会更新他们的应用程序以支持嵌套的 EIP-712 工作流程,因此智能账户实现应该 (SHOULD) 尝试通过在安全的情况下跳过防御性重新哈希来适应。

原理

TypedDataSign 结构

typedDataSignTypehash 必须在链上动态构造。这是为了强制签名内容在签名请求中可见,要求 contents 必须是用户定义的类型。

eip712Domain 的字段被展平到 TypedDataSign 结构中,而不是作为 EIP712Domain 类型的字段包含,以避免与验证合约的域类型发生冲突(如果不同)。

ERC-5267 中的 bytes1 fields 位图和 uint256[] extensions 数组已被省略。区分不存在的字段与零字段(例如 bytes32(0))不会为链上防御性重新哈希提供额外的安全优势。extensions 参数是用于链下信令的 EIP 编号列表。

隐式和显式模式的 contentsDescription

contents 结构包含嵌套类型时,EIP-712 字典排序可能会导致 contentsName 未精确定位在 contentsType 的开头。因此,我们需要显式模式。

使用 isValidSignature 进行支持检测

为了更容易在模块化智能账户中实现,我们已决定利用 isValidSignature 方法返回幻数,而不是定义新函数。

拒绝以任何小写 7 位 ASCII 字符开头的 contentsName

此建议是为了保持标准语言不可知且面向未来。原子类型(如 uint256)在其他语言中可能有不同的名称(例如 u256)。

向后兼容性

检测先前的草案

在先前的草案中,我们指定了一个 supportsNestedTypedDataSign() 函数用于支持检测,该函数返回 bytes4(0xd620c85a)

参考实现

提供了一个可用于生产且经过优化的实现以供参考

它包括安全、灵活性、开发人员体验和用户体验所需的相关的补充功能。

参考实现并非有意设计的极简。这是为了避免重蹈 ERC-1271 的覆辙,在 ERC-1271 中,极简的参考实现被错误地认为可以安全地用于生产。

安全考虑

拒绝无效的 contentsName

当前 eth_signTypedData 的主要实现不会清理自定义类型的名称。

网络钓鱼网站可以制作带有控制字符的 contentsName,以跳出 PersonalSign 类型编码,从而导致钱包客户端要求用户签署不透明的哈希。

要求链上清理 contentsName 将阻止此网络钓鱼攻击媒介。

不可能链接这种类型的多个签名者

对于 ERC-1271 签名,使用此方法作为重放保护的帐户不能有使用相同方法的签名者。这是因为签名定义了一个 TypedDataSign 结构类型,其成员具有正在签名的消息的类型,并且如果正在签名的消息是另一个 TypedDataSign 结构,则生成的 EIP-712 消息将在其主体中包含两个具有不兼容内容的单独的 TypedDataSign 类型,这是无法在 EIP-712 请求中表示的。

版权

CC0 下放弃版权及相关权利。

Citation

Please cite this document as:

vectorized (@vectorized), Sihoon Lee (@push0ebp), Francisco Giordano (@frangio), Hadrien Croubois (@Amxx), Ernesto García (@ernestognw), Im Juno (@junomonster), howydev (@howydev), Atarpara (@Atarpara), 0xcuriousapple (@0xcuriousapple), "ERC-7739: 智能账户的可读类型化签名 [DRAFT]," Ethereum Improvement Proposals, no. 7739, May 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7739.