本文深入探讨了以太坊中的数字签名和公钥加密技术,强调了密码学在区块链系统中的重要性。文章详细介绍了公钥密码学的基本原理、数字签名的生成和验证过程,以及如何利用EIP-712标准来解决常见的问题如重放攻击和签名可变性。
每个曾经接触过区块链系统(如以太坊)的人都知道区块链由什么组成,比如区块、交易和账户。但我们并不常去思考区块链系统的基础,就像我们不常思考我们的器官是如何工作的。正如器官需要血液和氧气才能正常运作,区块链也需要加密技术以正常运作。没有它,系统将会崩溃,而你将无法声称拥有你刚刚铸造的那个闪亮的新NFT。
早晚我们都需要谈论加密技术和签名。但请耐心等待,我们将尽可能让这些内容易于理解。
密码学的两个主要目的在于证明对一个秘密的知识而不泄露该秘密,以及证明数据的真实性(数字签名)。在以太坊中,密码学被广泛使用,而用户接触它的一个地方就是以太坊账户。
外部拥有账户(EOA)的所有权通过私钥和数字签名建立。私钥几乎在以太坊用户交互的所有地方被使用,EOA的以太坊地址是从私钥派生出来的。换句话说,以太坊地址是控制该账户的公钥的哈希的最后20个字节,在前面加上0x。
要证明你是EOA的真正拥有者,你需要用相应的私钥签署一条消息。这意味着只有你能够访问你账户中的资金。当你进行一笔交易,将1 Ether发送到一个合约以铸造一个新的NFT时,以太坊在后台验证你创建的数字签名(使用私钥)是否与相应账户的公钥哈希(地址)匹配。
这就像去银行要求从约翰·杜(John Doe)的账户提取1,000美元。银行首先需要确认请求取款的人确实是约翰·杜,而不是其他人。公钥密码学是基于数学函数的,这些函数允许生成唯一的公钥/私钥对。这些密钥对具有特殊属性,比如易于创建,但从公钥生成私钥极其困难(几乎不可能)。拥有私钥可以轻松生成公钥,但仅从公钥我们无法知道是哪个私钥被用于创建该公钥。
计算安全密钥的最常见数学方法之一是使用质数。如果我们给你一个数字6747437并告诉你它是由两个质数计算而来的,你将很难猜到使用的是哪两个质数。计算两个质数的乘积结果很容易,但反向操作则很困难。当然,我们使用了维基百科上的一个较小的质数,但如果我们使用两个大的质数,找到他们几乎很困难,即使是对于计算机。
正如我们所了解的,公钥密码学(也称为不对称加密)是一种使用密钥对系统的密码学方法。一把称为私钥的密钥签署消息。另一把称为公钥的密钥验证签名。当我们签署任何消息时,无论是在以太坊上的交易还是任何形式的数据,我们都会创建一个数字签名。这是通过哈希消息并运行ECDSA算法将哈希与私钥结合,生成签名来完成的。通过这样做,消息的任何变化都会导致不同的哈希值。
正如我们所读到的,在《掌握以太坊》 图书中,“**可以创建数字签名来签署任何消息。在以太坊交易中,交易本身的细节被用作消息。密码学的数学——在这种情况下是椭圆曲线密码学——提供了一种将消息(即交易细节)与私钥结合起来的方式,以创建一个只能在掌握私钥的情况下生成的代码。这个代码被称为数字签名。”
以上是另外一种关于数字签名的解释,但在以太坊交易的上下文中。这一解释将我们引入了另一个非常重要的主题——椭圆曲线密码学。
以太坊上的智能合约可以通过系统方法ecrecover
访问内置的ECDSA签名验证算法。该内置函数允许你验证已签名哈希数据的完整性并恢复签名者的公钥。
它使用了ECDSA中的V,R,S
值和消息的哈希。请记住,数字签名不仅仅与交易相关。使用私钥,我们可以对任何任意数据签名。感谢ecrecover
,我们在智能合约中有了一种验证签名的方法!这为我们打开了大量的机会,同时也带来了潜在的陷阱。现在让我们先关注积极的一面。
在以太坊智能合约上,签名验证的一种机会是创建元交易。元交易是一种将支付交易手续费的人与从交易执行中获益的人分开的方式。用户签署内层的元交易,然后将其发送给操作员或类似的对象——不需要支付手续费或与区块链交互。操作员会接收这个已签署的元交易,并将其提交到区块链上,自己支付外层常规交易的费用。
上述例子的典型是ERC20-Permit,标准化为ERC2612。标准ERC20面临的一个尴尬问题是,它需要经过两步过程才能允许智能合约使用用户资金。首先,我们需要创建一个approve()
交易。我们需要等待交易被挖矿,然后才能从合约本身调用transferFrom()
进行一些操作。此工作流的一个主要示例是DEX的使用。
当我们想要用USDC兑换WETH时,我们首先需要在USDC合约上调用approve()
以使DEX能够交易我们的USDC。然后,为了进行实际的交换,DEX将在第二笔交易中调用transferFrom()
来实现。执行一个简单操作却需要两次交易。
使用ERC20-Permit的permit函数,你只需用你的钱包签署元交易,然后其他人(如DEX或其他应用)可以代表你将其提交到区块链上。这将节省你的手续费,消除之前案例中的两次交易需要的问题。permit函数旨在使用户体验更加顺畅,并实现免手续费交易。
如果你想阅读有关ERC20-Permit的教程,我们推荐阅读链接中的博客文章。
现在,我们来到了第一个常见问题:有效签名可能在其他地方被多次使用,而这些地方并不打算使用它。
想象一个场景,我们有一个转移资金的函数,只在提供有效签名时才能执行。
乍一看,这段代码看起来不错。我们通过提供v,r,s
值来检查ECDSA签名的地址。我们将返回的地址与拥有者地址进行比较,如果是拥有者,我们就继续转移资金。
上述代码的问题在于由拥有者使用ECDSA算法签署的消息。消息仅包含接收者地址和要解锁的金额。消息中没有任何内容可以防止相同的签名被多次使用。
想象一个场景,拥有者通过transferFunds
向Alice(Alice)发送1 Ether,Alice可以重用相同的签名(V,R,S)再次向自己发送1 Ether,甚至多次重复这一操作以耗尽合约中的资金。
为了防止签名重放攻击,我们可以在executed
映射中存储我们所使用的签名。这样,无论何时有人想重放我们的签名,都会失败,因为我们可以通过检查映射来确认此签名是否已经被使用。
将上述内容结合,形成的代码函数如下所示:
如果你想查看完整的代码示例,我们推荐你查看Solidity-by-example 签名重放攻击章节。
上述代码仍然存在问题。它不遵循签名验证的推荐最佳实践,尤其是S
值。
在Solidity中,ECDSA签名由其_r, s_和_v_值表示。可以通过检查公钥、消息哈希、r、_s_和_v_之间的精确数学关系,以确保只有知道相应私钥的人才能计算r、_s_和v。然而,由于椭圆曲线的对称结构,对于每个r、_s_和_v_的集合,还有另一个易于计算的r、_s_和_v_集合,它也具有相同的精确数学关系。这导致出现两个有效签名,并且违背了“只有私钥持有者才能计算签名”的理念。
幸运的是,很容易发现重复的签名。
我们只对其中的一个有兴趣,因此我们需要一种方法来显示我们被展示的两个签名中哪一个。正如我们所说,椭圆曲线是对称的。_v_仅指示签名处于镜子的哪一侧。_v_可以是27(0x1b)或28(0x1c)。关于_v_的更多信息可以在Ethereum Yellow Paper Appendix F中找到。
https://github.com/ethereumbook/ethereumbook/blob/develop/images/simple_elliptic_curve.png
在谈论_s_点时,选择合适的“半部”是重要的。如上所示,椭圆曲线在X轴上是对称的,这意味着两个点可以具有相同的X值。我们可以仔细调整_s_以在X轴的另一侧生成有效签名。
所有这些的含义是,我们可以反转一个有效签名以获得另一个有效签名,这样仍然有效并基本上重放一个签名!有一种方法可以防止这种情况发生,且以太坊的第一个重大硬叉引入了一种解决方案:EIP-2。
EIP-2通过仅将较低_s_级别视为有效,来引入_s_值的限制以防止签名可塑性。通过将有效范围限制在一半,EIP-2有效地从组中移除了半数点,确保每个x坐标最多仅存在一个有效点。
虽然EIP-2已被引入到EVM中,但它未影响预编译合约ecrecover
。因此,每当我们使用普通的ecrecover
时,我们仍然容易受到签名可塑性的影响。不要担心,因为OpenZeppelin创建了一个合适的库 (ECDSA.sol) 来解决这个问题。
这个技巧很简单:我们将_s_值限制在下半部分。
应对签名可塑性和重放的另一种方法是使用应用层随机数。“Nonce”是密码学家对“一次性使用的数字”的简称。我们可以为每个签名使用一个随机数,并将下一个随机数存储在合约内部。
这涵盖了大多数常见的签名问题。但等等,还有更多。如果两个合约使用相同的消息编码(可能有一个不可替代的代币使用keccak256(abi.encodePacked(_to, _tokenId, nonce[_from])) )
,一个合约使用的签名可能对另一个合约也是有效的。因此我们需要进一步深入。我们必须把一些关于合约的标识信息哈希到我们的消息中,以确保其他合约不能使用该签名。
你可能会问自己(或问我们):是否有一些标准需要遵循,还是作为Solidity开发者,我应该手动实现所有内容?没有,感谢上帝有EIP和它为生态系统引入的标准。
为了帮助标准化以太坊中的签名使用,EIP-712 被引入,目前广泛使用,并被视为帮助开发者避免在处理签名时最常见安全问题的标准。
EIP-712的主要目标是确保用户确切理解他们所签署的内容、所针对的合约地址和网络,以及每个签名只能被目标合约使用。这是通过签署必要配置数据(地址、链ID、版本和数据类型)的哈希,以及实际数据来实现的。
EIP-712 是用于类型化结构化数据而不仅仅是字节串的哈希和签名标准。它包括:
eth_signTypedData
,以及上述标准ERC20-Permit 也依赖于EIP-712。我们将重点讲解OpenZeppelin的实现以解释EIP-712是如何工作的。下面的代码包含了一些来自ERC20Permit.sol和EIP712.sol的最重要代码片段。
领域分隔符:_domainSeparatorV4
确保签名仅在我们提供的代币合约地址的正确链ID上使用。继以太经典分叉后,它继续使用链ID为1,因此引入链ID是为了精确识别网络。因为在硬分叉之后,两个网络的同一地址上都有合约被部署,因此需要包含链ID以区分它们。
结构哈希:bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));
这个structHash
确保签名只能用于特定目的,即仅用于由拥有者调用的permit()
函数,批准指定值给支出者。它还添加了有关截止日期的检查,并检查随机数,以确保不能被重放。
最终EIP712哈希:bytes32 hash = _hashTypedDataV4(structHash);
这使用了EIP-191签名数据标准来定义一个版本号和特定数据。0x19作为设置前缀,0x01作为版本字节来指示这是一个EIP-712签名。随后,我们将领域分隔符和结构哈希打包在一起。我们得到的哈希就是我们的最终哈希消息。稍后,我们还可以继续提取签名者地址。请注意使用了ECDSA库来处理与签名相关的所有问题,如签名可塑性。
我们希望这篇文章能让你更好地理解以太坊中的数字签名以及如何有效管理它们。撰写本文的需要源于我们在各种项目中看到的签名处理问题增多。凭借之前的所有知识,你应该能够验证代码中的签名使用情况,并揭示一些问题。请记住,这只是对该主题的简要概述;还有很多内容值得学习。
- 原文链接: medium.com/immunefi/intr...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!