本文介绍了以太坊的签名标准 EIP-191 和 EIP-712,详细说明了这两项标准的背景、实现方式以及如何通过结构化数据防止重放攻击。文章提供了多个示例代码,讲解了签名的创建、验证过程及必要的安全措施。
了解你需要知道的一切关于以太坊改进提案(Ethereum Improvement Proposal)EIP-191、EIP-712 和以太坊签名标准。
要理解签名创建、验证和防止重放攻击的工作原理,首先需要理解以太坊改进提案 EIP-191 和 EIP-712。
在签署交易时,需要一种更简单的方式来读取交易数据。例如,在这些标准创建之前,在 MetaMask 中签署交易时显示的消息如下:
图像:在 MetaMask 中签署交易时未结构化的消息
这些标准意味着交易可以以可读的方式显示:
图像:在 MetaMask 中使用 EIP-712 签署的结构化消息
此外,EIP-712 是防止重放攻击的关键——防止重放攻击的数据编码在结构化数据中。
本文概述了这些标准、它们的动机以及如何实现它们。
— 本文的完整源代码由 Patrick Collins 编写,可以在 GitHub 上查看。
推荐阅读这篇 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 签名数据标准 提出了签名数据的以下格式:0x19 <1 byte version> <version specific data> <data to sign>
0x19
: 前缀。0x19
是因为在其他上下文中没有使用。<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-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 结构中的额外数据确保了重放防护。
为了防止重放攻击,智能合约必须:
s
值限制为单个半部分— 欲了解更多有关签名重放攻击及如何防止它们的信息,请参阅 这份综合指南 。
在关于 EIP-191 和 EIP-712 的本指南中,你已经了解了以太坊签名标准的工作原理。总结如下:
为了充分理解签名的创建、验证和重放,理解这两个标准至关重要。这种理解是编写安全智能合约的关键。
- 原文链接: cyfrin.io/blog/understan...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!