Alert Source Discuss
Standards Track: Interface

EIP-712: 类型化结构化数据哈希和签名

一种用于哈希和签名类型化结构化数据(而不仅仅是字节串)的过程。

Authors Remco Bloemen (@Recmo), Leonid Logvinov (@LogvinovLeon), Jacob Evans (@dekz)
Created 2017-09-12
Requires EIP-155, EIP-191

摘要

这是一个用于哈希和签名类型化结构化数据(而不仅仅是字节串)的标准。它包括

  • 编码函数正确性的理论框架,
  • 类似于并兼容 Solidity 结构的结构化数据规范,
  • 这些结构的实例的安全哈希算法,
  • 将这些实例安全地包含在可签名消息集中,
  • 用于域分离的可扩展机制,
  • 新的 RPC 调用 eth_signTypedData,以及
  • EVM 中哈希算法的优化实现。

它不包括重放保护。

动机

如果我们只关心字节串,那么签名数据就是一个已解决的问题。不幸的是,在现实世界中,我们关心的是复杂的有意义的消息。哈希结构化数据并非易事,错误会导致系统安全属性的丧失。

因此,“不要自己编写加密算法”这句格言适用。相反,需要使用经过同行评审的、经过良好测试的标准方法。这个 EIP 旨在成为该标准。

此 EIP 旨在提高链下消息签名的可用性,以便在链上使用。我们看到链下消息签名的采用越来越多,因为它节省了 gas 并减少了区块链上的交易数量。当前,已签名的消息是不透明的十六进制字符串,显示给用户的上下文很少,无法了解构成消息的项目。

eth_sign 截图

在这里,我们概述了一种编码数据及其结构的方案,该方案允许在签名时将其显示给用户以进行验证。下面是一个示例,说明了用户在根据本提案签署消息时可能看到的内容。

eth_signTypedData 截图

规范

可签名消息的集合从交易和字节串 𝕋 ∪ 𝔹⁸ⁿ 扩展到包括结构化数据 𝕊。因此,新的可签名消息集合是 𝕋 ∪ 𝔹⁸ⁿ ∪ 𝕊。它们被编码为适合哈希和签名的字节串,如下所示:

  • encode(transaction : 𝕋) = RLP_encode(transaction)
  • encode(message : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message,其中 len(message)message 中字节数的_非零填充_ ascii-十进制编码。
  • encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message),其中 domainSeparatorhashStruct(message) 定义如下。

这种编码是确定性的,因为各个组件都是确定性的。这种编码是单射的,因为这三种情况在第一个字节中始终不同。(RLP_encode(transaction) 不以 \x19 开头。)

该编码符合 EIP-191。 “版本字节”固定为 0x01,“版本特定数据”是 32 字节的域分隔符 domainSeparator,而“要签名的数据”是 32 字节的 hashStruct(message)

类型化结构化数据 𝕊 的定义

为了定义所有结构化数据的集合,我们首先定义可接受的类型。与 ABIv2 一样,这些类型与 Solidity 类型密切相关。采用 Solidity 表示法来说明这些定义是有启发意义的。该标准特定于以太坊虚拟机,但旨在与更高级别的语言无关。示例:

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

定义:_结构体类型_具有有效的标识符作为名称,并且包含零个或多个成员变量。成员变量具有成员类型和名称。

定义:_成员类型_可以是原子类型、动态类型或引用类型。

定义:_原子类型_为 bytes1bytes32uint8uint256int8int256booladdress。这些类型对应于它们在 Solidity 中的定义。请注意,没有别名 uintint。请注意,合约地址始终是纯 address。该标准不支持定点数。此标准的未来版本可能会添加新的原子类型。

定义:_动态类型_为 bytesstring。对于类型声明,这些类型类似于原子类型,但在编码中的处理方式有所不同。

定义:_引用类型_为数组和结构体。数组可以是固定大小或动态的,分别用 Type[n]Type[] 表示。结构体是通过其名称对其他结构体的引用。该标准支持递归结构体类型。

定义:结构化类型数据 𝕊 的集合包含所有结构体类型的所有实例。

hashStruct 的定义

hashStruct 函数定义为

  • hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s)),其中 typeHash = keccak256(encodeType(typeOf(s)))

注意typeHash 对于给定的结构体类型是一个常量,不需要在运行时计算。

encodeType 的定义

结构体的类型编码为 name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")",其中每个成员都写为 type ‖ " " ‖ name。例如,上面的 Mail 结构体编码为 Mail(address from,address to,string contents)

如果结构体类型引用其他结构体类型(而这些结构体类型又引用更多的结构体类型),则收集引用的结构体类型的集合,按名称排序并附加到编码中。一个示例编码是 Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)

encodeData 的定义

结构体实例的编码是 enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ),即按照类型中出现的顺序连接编码的成员值。每个编码的成员值的长度正好是 32 字节。

原子值的编码方式如下:布尔值 falsetrue 分别编码为 uint25601。地址编码为 uint160。整数值经过符号扩展到 256 位,并以大端顺序编码。bytes1bytes31 是具有开头(索引 0)和结尾(索引 length - 1)的数组,它们在末尾零填充到 bytes32,并按从头到尾的顺序编码。这对应于它们在 ABI v1 和 v2 中的编码。

动态值 bytesstring 被编码为它们内容的 keccak256 哈希值。

数组值被编码为它们内容连接的 encodeDatakeccak256 哈希值(即,SomeType[5] 的编码与包含五个 SomeType 类型成员的结构体的编码相同)。

结构体值递归地编码为 hashStruct(value)。这对于循环数据是未定义的。

domainSeparator 的定义

domainSeparator = hashStruct(eip712Domain)

其中 eip712Domain 的类型是名为 EIP712Domain 的结构体,具有以下一个或多个字段。协议设计者只需要包含对其签名域有意义的字段。未使用的字段将从结构体类型中省略。

  • string name 签名域的用户可读名称,即 DApp 或协议的名称。
  • string version 签名域的当前主版本。来自不同版本的签名是不兼容的。
  • uint256 chainId EIP-155 链 ID。如果与当前活动的链不匹配,用户代理_应该_拒绝签名。
  • address verifyingContract 将验证签名的合约的地址。用户代理_可以_进行特定于合约的网络钓鱼预防。
  • bytes32 salt 协议的消除歧义盐。这可以用作最后的手段的域分隔符。

对此标准的未来扩展可以添加具有新用户代理行为约束的新字段。用户代理可以自由地使用所提供的信息来告知/警告用户或拒绝签名。Dapp 实现者不应添加私有字段,新字段应通过 EIP 流程提出。

EIP712Domain 字段应按上述顺序排列,跳过任何缺少的字段。未来字段添加必须按字母顺序排列,并在上述字段之后。用户代理应接受 EIP712Domain 类型指定的任何顺序的字段。

eth_signTypedData JSON RPC 的规范

方法 eth_signTypedData 被添加到以太坊 JSON-RPC。该方法与 eth_sign 并行。

eth_signTypedData

签名方法使用以下方式计算以太坊特定的签名:sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))),如上所述。

注意:用于签名的地址必须已解锁。

参数
  1. Address - 20 字节 - 将对消息进行签名的帐户的地址。
  2. TypedData - 要签名的类型化结构化数据。

类型化数据是一个 JSON 对象,包含类型信息、域分隔符参数和消息对象。下面是 TypedData 参数的 json-schema 定义。

{
  type: 'object',
  properties: {
    types: {
      type: 'object',
      properties: {
        EIP712Domain: {type: 'array'},
      },
      additionalProperties: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            name: {type: 'string'},
            type: {type: 'string'}
          },
          required: ['name', 'type']
        }
      },
      required: ['EIP712Domain']
    },
    primaryType: {type: 'string'},
    domain: {type: 'object'},
    message: {type: 'object'}
  },
  required: ['types', 'primaryType', 'domain', 'message']
}
返回

DATA:签名。与 eth_sign 中一样,它是一个以 0x 开头的十六进制编码的 65 字节数组。它以大端格式编码黄皮书附录 F 中的 rsv 参数。字节 0…32 包含 r 参数,字节 32…64 包含 s 参数,最后一个字节包含 v 参数。请注意,v 参数包括 EIP-155 中指定的链 ID。

示例

请求:

curl -X POST --data '{"jsonrpc":"2.0","method":"eth_signTypedData","params":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", {"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","domain":{"name":"Ether Mail","version":"1","chainId":1,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}],"id":1}'

结果:

{
  "id":1,
  "jsonrpc": "2.0",
  "result": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"
}

有关如何使用 Solidity ecrecover 验证使用 eth_signTypedData 计算的签名的示例,请参见 Example.js。该合约已部署在测试网 Ropsten 和 Rinkeby 上。

personal_signTypedData

还应该有一个对应的 personal_signTypedData 方法,该方法接受帐户的密码作为最后一个参数。

Web3 API 的规范

Web3.js 版本 1 中添加了两种方法,它们与 web3.eth.signweb3.eth.personal.sign 方法并行。

web3.eth.signTypedData

web3.eth.signTypedData(typedData, address [, callback])

使用特定帐户对类型化数据进行签名。此帐户需要解锁。

参数
  1. Object - 要签名的域分隔符和类型化数据。根据上面 eth_signTypedData JSON RPC 调用中指定的 JSON-Schema 进行结构化。
  2. String|Number - 用于签名数据的地址。或 :ref:web3.eth.accounts.wallet <eth_accounts_wallet> 中的本地钱包的地址或索引。
  3. Function - (可选)可选的回调,将错误对象作为第一个参数返回,将结果作为第二个参数返回。

注意: 2. address 参数也可以是 web3.eth.accounts.wallet <eth_accounts_wallet> 中的地址或索引。然后,它将使用此帐户的私钥在本地进行签名。

返回

Promise 返回 String - eth_signTypedData 返回的签名。

示例

有关 typedData 的值,请参见上面的 eth_signTypedData JSON-API 示例。

web3.eth.signTypedData(typedData, "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826")
.then(console.log);
> "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"

web3.eth.personal.signTypedData

web3.eth.personal.signTypedData(typedData, address, password [, callback])

web3.eth.signTypedData 相同,但有一个额外的 password 参数,类似于 web3.eth.personal.sign

理由

encode 函数使用新的类型扩展了一个新的案例。编码的第一个字节区分了这些案例。出于同样的原因,立即以域分隔符或 typeHash 开头是不安全的。虽然困难,但可能会构造一个 typeHash,它也恰好是有效的 RLP 编码交易的前缀。

域分隔符可防止其他相同结构的冲突。两个 DApp 有可能提出一个相同的结构,如 Transfer(address from,address to,uint256 amount),这些结构不应兼容。通过引入域分隔符,DApp 开发人员可以保证不会发生签名冲突。

域分隔符还允许在给定 DApp 中对同一结构实例进行多次不同的签名用例。在前面的示例中,可能需要来自 fromto 的签名。通过提供两个不同的域分隔符,可以将这些签名彼此区分开来。

替代方案 1:使用目标合约地址作为域分隔符。这解决了第一个问题,即合约提出相同类型,但没有解决第二个用例。该标准确实建议实施者在适当的地方使用目标合约地址。

函数 hashStructtypeHash 开头以分隔类型。通过为不同的类型提供不同的前缀,encodeData 函数仅需在给定类型内是单射的。只要 typeOf(a) 不是 typeOf(b)encodeData(a) 等于 encodeData(b) 就没问题。

typeHash 的理由

typeHash 旨在转换为 Solidity 中的编译时常量。例如:

bytes32 constant MAIL_TYPEHASH = keccak256(
  "Mail(address from,address to,string contents)");

对于类型哈希,出于以下原因考虑并拒绝了几种替代方案:

替代方案 2:使用 ABIv2 函数签名。 bytes4 不足以抵抗冲突。与函数签名不同,使用更长的哈希产生的运行时成本可以忽略不计。

替代方案 3:修改后的 ABIv2 函数签名是 256 位。虽然这捕获了类型信息,但它没有捕获函数以外的任何语义。这已经导致了 EIP-20EIP-721transfer(address,uint256) 之间的实际冲突,其中前者中的 uint256 指的是金额,后者指的是唯一的 ID。一般来说,ABIv2 倾向于兼容性,而哈希标准应该倾向于不兼容性。

替代方案 4:256 位 ABIv2 签名,扩展了参数名称和结构体类型名称。上面 Mail 示例将编码为 Mail(Person(string name,address wallet) from,Person(string name,address wallet) to,string contents)。这比提议的解决方案更长。实际上,字符串的长度可能会随着输入的长度呈指数增长(考虑 struct A{B a;B b;}; struct B {C a;C b;}; …)。它也不允许递归结构体类型(考虑 struct List {uint256 value; List next;})。

替代方案 5:包括 natspec 文档。这将在 schemaHash 中包含更多的语义信息,并进一步降低冲突的可能性。它使扩展和修改文档成为一项重大更改,这与常见的假设相矛盾。它还使 schemaHash 机制非常冗长。

encodeData 的理由

encodeData 旨在允许在 Solidity 中轻松实现 hashStruct

function hashStruct(Mail memory mail) pure returns (bytes32 hash) {
    return keccak256(abi.encode(
        MAIL_TYPEHASH,
        mail.from,
        mail.to,
        keccak256(mail.contents)
    ));
}

它还允许 EVM 中有效的位置实现

function hashStruct(Mail memory mail) pure returns (bytes32 hash) {

    // 计算子哈希
    bytes32 typeHash = MAIL_TYPEHASH;
    bytes32 contentsHash = keccak256(mail.contents);

    assembly {
        // Back up select memory
        let temp1 := mload(sub(mail, 32))
        let temp2 := mload(add(mail, 128))

        // 写入 typeHash 和子哈希
        mstore(sub(mail, 32), typeHash)
        mstore(add(mail, 64), contentsHash)

        // 计算哈希
        hash := keccak256(sub(mail, 32), 128)

        // 恢复内存
        mstore(sub(mail, 32), temp1)
        mstore(add(mail, 64), temp2)
    }
}

位置实现对内存中结构体的内存布局做出了强烈但合理的假设。具体来说,它假设结构体未在地址 32 以下分配,成员按顺序存储,所有值都填充到 32 字节边界,并且动态类型和引用类型存储为 32 字节指针。

替代方案 6:紧密打包。这是在 Solidity 中使用多个参数调用 keccak256 时的默认行为。它最大限度地减少了要哈希的字节数,但需要 EVM 中复杂的打包指令才能这样做。它不允许就地计算。

替代方案 7:ABIv2 编码。特别是对于即将推出的 abi.encode,应该很容易使用 abi.encode 作为 encodeData 函数。ABIv2 标准本身不符合确定性安全标准。相同的 data 有几种有效的 ABIv2 编码。ABIv2 不允许就地计算。

替代方案 8:将 typeHash 排除在 hashStruct 之外,而是将其与域分隔符组合。这更有效,但随后 Solidity keccak256 哈希函数的语义不是单射的。

替代方案 9:支持循环数据结构。当前标准针对树状数据结构进行了优化,对于循环数据结构未定义。为了支持循环数据,需要维护一个包含到当前节点的路径的堆栈,并在检测到循环时替换堆栈偏移量。指定和实现这一点非常复杂。它还破坏了可组合性,其中成员值的哈希用于构造结构体的哈希(成员值的哈希将取决于路径)。可以以兼容的方式扩展标准以定义循环数据的哈希。

同样,对于有向无环图,直接的实现是次优的。通过成员的简单递归可以多次访问同一个节点。Memoization 可以优化这一点。

domainSeparator 的理由

由于不同的域有不同的需求,因此使用了一种可扩展的方案,其中 DApp 指定一个 EIP712Domain 结构体类型和一个实例 eip712Domain,DApp 将其传递给用户代理。然后,用户代理可以根据存在的字段应用不同的验证措施。

向后兼容性

RPC 调用,web3 方法和 SomeStruct.typeHash 参数当前未定义。定义它们不应影响现有 DApp 的行为。

对于结构体类型 SomeStruct 的实例 someInstance,Solidity 表达式 keccak256(someInstance) 是有效的语法。它当前评估为实例的内存地址的 keccak256 哈希值。此行为应被视为危险。在某些情况下,它似乎可以正常工作,但在其他情况下,它将导致确定性和/或单射性失败。依赖于当前行为的 DApp 应被视为危险的破坏。

测试用例

示例合约可以在 Example.sol 中找到,示例 JavaScript 签名实现可以在 Example.js 中找到。

安全注意事项

重放攻击

此标准仅涉及签名消息和验证签名。在许多实际应用中,签名消息用于授权操作,例如代币交换。实施者确保应用程序在看到同一签名消息两次时行为正确_非常重要_。例如,重复的消息应该被拒绝,或者授权的操作应该是幂等的。如何实现这一点特定于应用程序,并且超出此标准的范围。

抢先交易攻击

可靠广播签名的机制是特定于应用程序的,并且超出此标准的范围。当签名广播到区块链以供合约使用时,应用程序必须防止抢先交易攻击。在这种攻击中,攻击者拦截签名并在原始预期用途发生之前将其提交给合约。当签名首先由攻击者提交时,应用程序的行为应正确,例如拒绝签名或仅产生与签名者预期的完全相同的效果。

版权

通过 CC0 放弃版权和相关权利。

Citation

Please cite this document as:

Remco Bloemen (@Recmo), Leonid Logvinov (@LogvinovLeon), Jacob Evans (@dekz), "EIP-712: 类型化结构化数据哈希和签名," Ethereum Improvement Proposals, no. 712, September 2017. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-712.