EIP-712: 类型化结构化数据哈希和签名
一种用于哈希和签名类型化结构化数据(而不仅仅是字节串)的过程。
Authors | Remco Bloemen (@Recmo), Leonid Logvinov (@LogvinovLeon), Jacob Evans (@dekz) |
---|---|
Created | 2017-09-12 |
Requires | EIP-155, EIP-191 |
Table of Contents
摘要
这是一个用于哈希和签名类型化结构化数据(而不仅仅是字节串)的标准。它包括
- 编码函数正确性的理论框架,
- 类似于并兼容 Solidity 结构的结构化数据规范,
- 这些结构的实例的安全哈希算法,
- 将这些实例安全地包含在可签名消息集中,
- 用于域分离的可扩展机制,
- 新的 RPC 调用
eth_signTypedData
,以及 - EVM 中哈希算法的优化实现。
它不包括重放保护。
动机
如果我们只关心字节串,那么签名数据就是一个已解决的问题。不幸的是,在现实世界中,我们关心的是复杂的有意义的消息。哈希结构化数据并非易事,错误会导致系统安全属性的丧失。
因此,“不要自己编写加密算法”这句格言适用。相反,需要使用经过同行评审的、经过良好测试的标准方法。这个 EIP 旨在成为该标准。
此 EIP 旨在提高链下消息签名的可用性,以便在链上使用。我们看到链下消息签名的采用越来越多,因为它节省了 gas 并减少了区块链上的交易数量。当前,已签名的消息是不透明的十六进制字符串,显示给用户的上下文很少,无法了解构成消息的项目。
在这里,我们概述了一种编码数据及其结构的方案,该方案允许在签名时将其显示给用户以进行验证。下面是一个示例,说明了用户在根据本提案签署消息时可能看到的内容。
规范
可签名消息的集合从交易和字节串 𝕋 ∪ 𝔹⁸ⁿ
扩展到包括结构化数据 𝕊
。因此,新的可签名消息集合是 𝕋 ∪ 𝔹⁸ⁿ ∪ 𝕊
。它们被编码为适合哈希和签名的字节串,如下所示:
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)
,其中domainSeparator
和hashStruct(message)
定义如下。
这种编码是确定性的,因为各个组件都是确定性的。这种编码是单射的,因为这三种情况在第一个字节中始终不同。(RLP_encode(transaction)
不以 \x19
开头。)
该编码符合 EIP-191。 “版本字节”固定为 0x01
,“版本特定数据”是 32 字节的域分隔符 domainSeparator
,而“要签名的数据”是 32 字节的 hashStruct(message)
。
类型化结构化数据 𝕊
的定义
为了定义所有结构化数据的集合,我们首先定义可接受的类型。与 ABIv2 一样,这些类型与 Solidity 类型密切相关。采用 Solidity 表示法来说明这些定义是有启发意义的。该标准特定于以太坊虚拟机,但旨在与更高级别的语言无关。示例:
struct Mail {
address from;
address to;
string contents;
}
定义:_结构体类型_具有有效的标识符作为名称,并且包含零个或多个成员变量。成员变量具有成员类型和名称。
定义:_成员类型_可以是原子类型、动态类型或引用类型。
定义:_原子类型_为 bytes1
到 bytes32
、uint8
到 uint256
、int8
到 int256
、bool
和 address
。这些类型对应于它们在 Solidity 中的定义。请注意,没有别名 uint
和 int
。请注意,合约地址始终是纯 address
。该标准不支持定点数。此标准的未来版本可能会添加新的原子类型。
定义:_动态类型_为 bytes
和 string
。对于类型声明,这些类型类似于原子类型,但在编码中的处理方式有所不同。
定义:_引用类型_为数组和结构体。数组可以是固定大小或动态的,分别用 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 字节。
原子值的编码方式如下:布尔值 false
和 true
分别编码为 uint256
值 0
和 1
。地址编码为 uint160
。整数值经过符号扩展到 256 位,并以大端顺序编码。bytes1
到 bytes31
是具有开头(索引 0
)和结尾(索引 length - 1
)的数组,它们在末尾零填充到 bytes32
,并按从头到尾的顺序编码。这对应于它们在 ABI v1 和 v2 中的编码。
动态值 bytes
和 string
被编码为它们内容的 keccak256
哈希值。
数组值被编码为它们内容连接的 encodeData
的 keccak256
哈希值(即,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)))
,如上所述。
注意:用于签名的地址必须已解锁。
参数
Address
- 20 字节 - 将对消息进行签名的帐户的地址。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 中的 r
、s
和 v
参数。字节 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.sign
和 web3.eth.personal.sign
方法并行。
web3.eth.signTypedData
web3.eth.signTypedData(typedData, address [, callback])
使用特定帐户对类型化数据进行签名。此帐户需要解锁。
参数
Object
- 要签名的域分隔符和类型化数据。根据上面eth_signTypedData
JSON RPC 调用中指定的 JSON-Schema 进行结构化。String|Number
- 用于签名数据的地址。或 :ref:web3.eth.accounts.wallet <eth_accounts_wallet>
中的本地钱包的地址或索引。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 中对同一结构实例进行多次不同的签名用例。在前面的示例中,可能需要来自 from
和 to
的签名。通过提供两个不同的域分隔符,可以将这些签名彼此区分开来。
替代方案 1:使用目标合约地址作为域分隔符。这解决了第一个问题,即合约提出相同类型,但没有解决第二个用例。该标准确实建议实施者在适当的地方使用目标合约地址。
函数 hashStruct
以 typeHash
开头以分隔类型。通过为不同的类型提供不同的前缀,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-20 和 EIP-721 的 transfer(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.