作者: @mmjahanara , @pierre
感谢@b-wagn 、 @soispoke和@levs57的有益讨论和评论,促成了本文的撰写。
简而言之,我们探讨了虫洞的“似是而非的可否认性”为何似乎与以太坊的 160 位地址不兼容。此外,我们还阐明了 EIP-7503 声称的匿名集远低于实际预期。虽然后一个问题应该可以解决,但我们认为前一个问题可能需要重新思考虫洞的整体架构。文章最后简要介绍了一种使用信标链存款的潜在后续设计方案。
关于虫洞
以太坊的隐私解决方案历来依赖于特定应用的匿名集。在诸如Tornado Cash之类的协议中,存款行为是与特定智能合约的显式交互。这造成了一个根本性的缺陷:虽然存款人和取款人之间的联系被切断,但参与隐私协议的行为却是公开的。这些设计使得链上观察者可以标记并可能审查所有匿名集中的存款人。
EIP-7503(零知识虫洞)提出了一种不同的范式,依赖于可信的否认。
该机制很简单:用户通过将资金发送到一个由加密技术确定的不可花费地址来“销毁”资金。之后,他们提供一个(非交互式)零知识证明(NIZK),证明他们知道该地址的原像,以便在新的账户中重新铸造这些资金。关键在于,“存款”(或“销毁”)与向新地址的标准以太坊转账没有任何区别。
然而,我们认为可信的否认性和安全性之间可能存在根本性的权衡,这可能会阻碍虫洞目前在L1层中的应用。此外,我们还指出EIP-7503的有效匿名集可能低于预期,并对此问题进行了探讨。
符号和假设
我们将使用:
- \mathsf{H}(\cdot) H ( ⋅ )表示通用的 256 位哈希函数(例如,SHA3、SHA-256 等)。
- \operatorname{trunc}_{160}(x) trunc 160 ( x )将 256 位值x x截断为 160 位以太坊地址。
- 域分隔哈希为\mathsf{H}(\text{“TAG”} \parallel \cdots) H ( “TAG” ∥ ⋯ ) ,其中\text{“TAG”} “TAG”是一个固定的 ASCII 前缀(例如\text{“worm”} “worm” , \text{“null”} “null” )。
EIP-7503
在最初的 EIP-7503 规范中,销毁地址是由单个密钥s派生而来的:
第一步,用户通过随机选择一个 s并将其发送到Addr_{burn}(s) A d d r b u r n ( s )来销毁其资金。之后,用户可以通过向智能合约提供一个无效化符ν = \mathsf{H}(\text{“null”} \parallel s) ν = H ( “null” ∥ s )和一个 NIZK 证明来增发资金,该证明需证明ν与某个现有交易A\to B A → B一致(即,使用s s st B = Addr_{burn}(s) B = A d d r b u r n ( s ) )。合约验证该证明并检查ν ν是否已被提交过。如果这些检查都通过,则增发资金。
这种销毁机制的安全特性并不明确,所以让我们非正式地定义一下。
正确性/完整性
如果用户行为诚实,那么燃烧后可能会产生收益。
隐私
- 不可链接性
直观地说,这里的隐私意味着除了用户本人之外,任何人都无法将销毁交易与铸币过程(即提交给铸币合约的铸币交易)关联起来。更准确地说,我们假设用户在这种情况下是诚实的,并且希望链上的任何观察者都无法分辨出哪个交易是与铸币交易对应的销毁交易。
- 合理否认
我们希望实现的第二个隐私属性是,销毁交易应该看起来像一笔“常规”交易。具体来说,销毁地址B应该看起来像一个随机地址。这种可否认性非常重要,也是它与需要显式合约交互的 Tornado Cash 等协议的主要区别之一。使用虫洞,参与销毁协议的过程是私密的——但请注意,提现操作仍然是公开的。
非通货膨胀
我们不希望以太币凭空产生,这意味着只有在销毁之后才能再次铸造:任何成功的铸造交易到销毁交易的映射都应该是单射的。乍一看,这意味着要防止攻击者为同一笔销毁交易生成两个不同的无效化符。事实上,如果攻击者能够为同一笔销毁交易生成两个不同的无效化符,我们就会导致通货膨胀漏洞。
既然\mathsf{H}(\cdot) H ( ⋅ )隐藏起来了,那岂不是很简单吗?嗯,正如Scroll 团队很早就注意到的那样,事情的难点就在这里。
生日悖论
生日悖论意味着,找到任意两个秘密s_1, s_2,使得它们的160位地址冲突
大约需要2^{80} 2 80 次运算。虽然数量庞大,但对于国家级黑客组织或大型矿池来说,这样的运算量仍在可控范围内。当攻击者发现此类碰撞时,协议就会失效:
- 一次性向冲突地址存入 100 ETH。
- 使用无效化符\nu_1=\mathsf{H}(\text{"null"} \parallel s_1) ν 1 = H ( "null" ∥ s 1 )和\nu_2=\mathsf{H}(\text{"null"} \parallel s_2) ν 2 = H ( "null" ∥ s 2 )进行铸造。这是两个不同的无效化符,因此 100 ETH 被铸造了两次。
- 提取 200 ETH。
由于验证器仅检查每个无效化符是否被使用过一次,因此它无法判断这两次提款是否由同一笔链上存款支持。这是一个无限膨胀漏洞:在2^{80}生日边界处发生的 160 位地址冲突会直接导致双倍铸币。
然而,需要注意的是,拥有更大地址空间的链不会面临这种安全缺陷:使用完整的 320 位地址,该方案的安全性将令人满意。以太坊的 160 位截断是一种优化,理论上它可以摆脱这种限制。
暂定方案:移除冲突搜索,并使用 dlog 使方案更难被破坏?
让我们提出一个想法:我们能否绕过生日悖论,不要求攻击者找到碰撞,而是要求他破坏 dlog?
假设我们对语句(\mathcal{R}_{root}, \nu) ( R r o o t , ν )和见证(sk,s_{worm},tx = (A,B,pk),\text{salt},\pi_{merkle}) ( s k , s w o r m , t x = ( A , B , p k ) , salt , π m e r k l e )进行如下 NIZK 运算,并满足以下约束:
(1) \pi_{merkle} π m e r k l e验证tx t x在\mathcal{R}_{root} R r o o t 中;
(2) pk == \mathsf{SkToPk}(sk) p k = = S k T o P k ( s k ) ,即, pk = sk \cdot G p k = s k ⋅ G对于 ECDSA 密钥;
(4) B == \operatorname{trunc}_{160}\!\big(\mathsf{H}(\text{"worm"} \parallel sk \parallel \text{salt})\big) B == trunc 160 ( H ( "worm" ∥ s k ∥ salt ) ) ;
(5) \nu == \mathsf{H}(\text{"null"} \parallel sk \parallel tx). ν = = H ( "null" ∥ s k ∥ t x ) 。
这样的设置就太好了:用户甚至可以在两个不同的销毁交易中使用相同的盐值时重复使用其销毁地址!
安全性论证也应该很直接:双重铸币现在需要破解 NIZK、ECDSA 或用于ν ν的哈希,而不是利用2^{80} 2 80成本的地址碰撞。事实上,对于固定的销毁交易(因此A、pk 、 B也是固定的) ,只有一个有效的sk s k ,否则你就可以找到两个不同的私钥映射到发起该交易的同一个公钥。因此,所有碰撞的见证都映射到同一个无效化符。
这就是解决方案吗?
其实不然,因为防止无效化碰撞并不是唯一的问题!
虫洞机制也应防止销毁地址被花费。正如最初的 EIP-7503 所述,攻击者可以找到一对密钥(s_1, s_2) ,使得create2_address ( ... , s1 create2_address(..., s1) == sha256("wormhole" || s2) 。在这种情况下,攻击者可以将资金销毁到sha256("wormhole" || s2) ,然后从create2_address(..., s1)提取这些资金。虽然这意味着攻击者只能提取两次以太币,但这仍然应被视为攻击失败。
但考虑到我们上面更新的电路,我们真的需要担心吗?既然我们的设计需要攻击者破坏 dlog,我们难道不能简单地要求在销毁资金时EXTCODESIZE不为0吗?这样就能防止攻击者从create2_address(..., s1)中取出销毁的资金。
问题在于,首先就应该意识到上述方案需要攻击者破解 dlog。事实上,无论是否掌握私钥,攻击者控制销毁地址的策略与使用CREATE2操作码的策略几乎相同。
在我们的设置中,为了使销毁地址可以花费,游戏规则如下:如果攻击者能够找到两个秘密(s_1, s_2) ( s 1 , s 2 ) ,使得trunc160(H(\text{“worm”} || s_1)) == trunc160(H(skToPk(s_2))) tr u n c 160 ( H ( “ worm ” | | s 1 ) ) = = tr u n c 160 ( H ( sk To P k ( s 2 ) ) ) ,则他获胜。事实上,在这种情况下,攻击者最终会得到一个可花费的销毁地址,因为找到这样的对可以让攻击者发起从trunc160(H(skToPk(s_1))) t r u n c 160 ( H ( sk To P k ( s 1 ) ) )到trunc160 (H(\text{“worm”} || s1)) t r u n c 160 ( H ( “worm” | | s 1 ) )的交易,该地址由s_2 s 2控制!
我们又回到了原点:这仍然是一个碰撞搜索问题,只是复杂度只有 80 位。
如今那些看似可信但实际上不可否认的虫洞需要具备一定的抗碰撞能力。
忽略检测启发式方法,虫洞的合理否认性要求意味着,任何观察L1层的人都无法区分虫洞交易和常规交易。这一特性自然要求销毁地址的计算过程对发起销毁的用户保密。但是,由于抗碰撞机制的存在,用户无法以超过80比特的安全级别证明,他们用于生成160比特私有销毁地址的密钥不会同样控制该地址。
一种解决办法是将销毁地址直接写入协议本身(例如,将资金发送到trunc160(H ( " worm " ) ) ) ,从而将问题转化为原像搜索任务。但这种设置会让我们失去所寻求的合理否认性。最终,我们不得不在安全性较弱但合理否认的设计和几乎等同于 Tornado Cash 的另一种设计之间做出选择。
我们在此列出三个我们认为对改善虫洞现状有价值的问题:
- 我们是否可以利用其他原始技术,在L1层中构建看似可信却又难以否认的虫洞?我们认为答案是肯定的,并正在着手研究如何利用信标存金来实现这一目标。以下附录将简要阐述这一想法。
- 是否有可能利用原像搜索问题构建虫洞,并实现合理的否认?我们对此并不确定,使用确定性但私密的销毁地址似乎相当困难。
- 是否存在一些容易实现的 L1 更改,可以让我们肯定地回答上述两个问题中的一个?
附加组件
EIP-7503 有效匿名集
我们有一个实际问题,不确定是否已经得到充分讨论。因此,我们在此说明,以求清晰明了。
EIP-7503 将beacon_block_root作为铸币证明的公开输入。但此举将声称的匿名集(所有零交易记录的以太坊账户)缩小到该区块内发生的交易,因为相应的销毁交易必须位于被引用的区块中。如果虫洞协议决定使用交易收据根作为用户铸币证明的输入,则要保持足够大的匿名集,就需要访问一个能够获取所有先前交易认证路径的累加器。
虽然理论上这应该可行,因为每个区块根哈希都是对该区块之前所有交易的承诺,但此证明要么需要高效的客户端递归 snark,要么需要特定的 L1 设置——例如用易于算术运算的哈希替换 keccak,或者用一个字段扩展区块头,该字段承诺到包含所有先前区块哈希的 merklized 区块根。
后续工作:虫洞与信标沉积物相遇
信标存款合约是执行层与信标链之间的单向桥梁。目前部署的方案是,每个验证器都可以公开链接到其存款和取款目的地:该合约公开了一个deposit函数,该函数接受存款以及验证器pubkey和取款凭证( withdrawal_credentials )。
或许可以设计一个私有版本的合约,用相应的隐藏承诺替换pubkey和withdrawal_credentials 。存款随后可以通过在信标链上提供零知识证明来领取。在这种架构中,验证者只有在存款在信标链上被领取后才会被添加到激活队列。需要注意的是,未领取的存款仍然可以像任何常规验证者一样添加到取款队列中。
这需要对协议进行一些架构重构,但它可以显著提升验证者的隐私性,同时还能作为一种协议内类似虫洞的机制,提供合理的否认能力。此外,考虑到大约三分之一的以太坊已被质押,且每日提现额高达数千万美元(甚至更多),由此产生的匿名集规模可能相当庞大。
