零知识证明zkSNARKs详解

2,289 阅读20分钟

本文为个人对零知识证明,主要是zkSNARKs的笔记,写作略显杂乱,内容很长,记录了很多疑问和思考。 同时包括了ETH上去中心化匿名混币TornadoCash和BTC的半中心化匿名混币CoinJoin的对比。

非对称加密:朴素“零知识证明”

我想向你证明我有一个“秘密”(Secret),但我又不想直接告诉你,怎么办?

非对称密码学,能在不泄露私钥的情况下,公开证明我拥有私钥。因为使用私钥能产生一个签名,而只有私钥产生的签名能通过对应公钥的验证,不拥有私钥者无法造假。

比如加密签名邮件,我同时还需要告知对方我的公钥指纹。但这个预设的限制依旧太大,而且,公私钥这种朴素零知识证明只能证明“我有私钥对应的公钥”这一个事实,不能证明其它东西,应用场景受限。

zkSNARKs:我的理解

什么叫“我有一个秘密”?在我们零知识证明这个语境下秘密的定义是什么?秘密指的是我们对某个“问题”的一个解,这个问题则被一段(一系列断言组成的)程序所描述。我们把这个秘密答案带回原问题程序,所有断言都成立,程序能通过。所以我们能以明文判定我们这个秘密是正确的,它是个验证者所想要的“秘密”。

现在,我们不想用明文验证,我们更不想让验证者知道这个秘密的明文。对于零知识证明有很多科普文章中的比喻,比如什么“不能区分的虚拟世界+时间回溯机器”、“一个山洞里有个环路,环路中间有个门”,我觉得这些比喻都过度抽象(模拟器比喻很正确但是过于抽象),而且没有贴近零知识证明的数学本质。

我最欣赏的比喻是数独验证机比喻:

一个数独引发的血案

小明小红做数独,小明不想直接告诉小红答案,而是利用数独这个“问题”的性质,设计了一套判定程序:每行、每列、每个九宫格必须都包含1-9,或者说元素求和必须等于45。这个reduce过程完全消灭了还原出原来题解的可能,然后两人先是交互式地跑这套验证流程,再把它做成一个自动化的验证机,这个验证机需要输入原文题解,然后会输出一个个纸袋供外部验证。

这套程序虽然不是真正的零知识证明程序,但是它已经把大概流程都给我们说了一遍了。以下内容为个人理解,参考了系列文章:

zhuanlan.zhihu.com/p/99260386

以及Vitalik的文章:

medium.com/@VitalikBut…

但我真的觉得这两篇理解起来依旧很困难。

从程序电路(R1CS)到多项式(QAP)

一个问题:我们要验证的程序是图灵完备的吗?

当然不是!因为我们总不可能验证无限多个约束条件断言。因此这个程序最终必须停机并返回ACC,或者断言不通过返回REJ。

而且我们也不需要 ,因为秘密解可以被我们设计为定长的,因此对其进行的一系列验证操作也应该是有限的。

zhuanlan.zhihu.com/p/102090192

每一条程序指令都可表示为: 左操作数 运算符 右操作数 = 输出 的形式。 在 左操作数 右操作数 输出 这三个位置上可能出现许多不同的变量。我们通过变量多项式(在x取自然数时,要么为某个正值要么为0)来控制某个变量在第几条程序的某个位置上是否出现。

为变量赋值(输入有关秘密解的信息)的过程就是给这些变量多项式乘以整体系数,使其在该出现的地方为某个你想要的值,而不出现的地方则为0。

将所有这些赋值了的变量多项式分别相加得到L、R、O。

L x R - O = h x t

为什么可以直接相加然后相乘?因为我们要确保程序的连贯性,所以相乘;而且由于变量多项式会把所有不相干变量设0,L、R在x=自然数的点上的值的乘积就是对应程序指令。

其中t多项式的意思是,这个多项式程序有x从1到n的n个根,也就是说n条程序约束都成立。

What's the POINT?

整套系统的唯一目的是:

Verifier 能判定:根据电路规则,经由Prover赋值后的多项式 LR - O 是否确实在1, 2, 3 ... d处都有根。 也即,LR - O = ht 是否对于任取的点 x = s 成立,其中 t(x) = (x - 1)(x - 2)....(x - d),h为 (LR - O)/t

流程:

Prover:我有你提供的电路问题的解。

Verifier:我已经有完备的Setup过程,利用其提供的值证明给我看。

Prover:我使用了你提供的电路(以及对应的已经代入取点s的变量多项式的值)且只能对它们整体乘以系数实现赋值(Prover改动不了某个变量的具体多项式了),且我只能使用它们来对应生成我的L、R、O,不能交换或替换,因为我不知道alpha,无法生成alpha关系;你还提供了加密的某个随机数s(取的随机点)的各个次幂s1 s2 ... sd,让我能根据我的h(x)计算出h(s)。

关键内容: Prover:为了方便你验证我在证明中使用的某个变量是否与你那边的明文一致,我将LRO分为你一半我一半,并对你提供我这一半输入值的L、R、O、以及完整输入的h的加密值,以及其对应的alpha变换版本,和一个beta-gamma校验和的加密值gZ。你可以生成你那一半输入值的L、R、O并且验证,总和L、R、O以及算出来的h,在随便选的一个谁都不知道的点上,是能满足L x R - O = ht的关系的,P确实有1, 2, 3...d这些根,这也就是说我拥有的多项式的赋值输入,确实可以使得你电路中的所有断言均通过,而且其中属于你那一半的输入值确实是你所知道的那些明文,程序认可我的输入!

Prover:并且由于你的约束,我也不可以互换变量多项式或者改变其组合、顺序,也不可以对不出现同一个位置(L、R、O)上的同一变量赋两个值,因为我不知道分别锁住L、R、O的beta,无法生成beta关系;同时由于gamma关系的存在,证明过程已经消灭了延展性,我不能利用已知的alpha beta的加密值(现在已经未知)钻空子生成alpha beta关系。

Prover:我再引入多个随机数,这不会破坏上述的所有数学关系,虽然你仍然可以验证,但你不可能从我给你的数个加密值的关系里反推回任何我的原始赋值了,因为他们就和几个随机数没区别。

注:一个能走到最后验证等式是否成立的输入(L, R, O, h)必定已经经受了前面变量多项式约束、不可交换约束、一致性约束等种种考验。此时,L、R、O必定是通过Setup中生成的电路(已经取随机点s)输入赋值得来的。 在整个约束过程中有一句话非常重要:不知道明文只知道密文,你Prover是无法产生约束关系的,只能从某处继承这个关系,而对方Verifier就能利用这个约束关系强制你必须如何操作或者遵守某个规则,如果你的输入不保证、不满足这个约束关系,我就知道你在尝试作弊。 这里,t(s)的加密值gt(s)其实也可以看作是一种约束关系!因为t(x)是公开的明文,但是带入sk之后它就成了gt(s),根本原因在于s是未知的。它和alpha(数乘操作上的关系)beta gamma(配对操作上的关系)类似,但是这个关系产生的根本蕴含在LR - O的固有性质,也就是能提取出因式t(x)这个性质中,而和计算产生的 h 无关。随机数s是Setup阶段产生的采样点,而之后s是未知的,t(s)也始终以密文形式出现。Prover不能凭空制造一个 t(s) 关系(而且也无法通过其它约束),也不能制造其逆关系(不能产生一个能通过之前约束检测的非法LR - O,再强凑出个假的 h(s) 糊弄计算检查)。Prover只能先保证RxLx - Ox = h(x) t(x),对于每个 x 都成立(合法);然后再通过把s的幂的加密值、含s的变量多项式分别带入的方式,来获得在配对操作上的t(s)关系(由一般到特殊)。这样,一个固定不变但是未知的 s,其表现却像每次都是随机任取的一样。所以单单靠一个对 Prover 未知的随机点 s 上的采样,就能极大概率保证RxLx - Ox = h(x) t(x)对于每个 x 都成立!

匿名技术:CoinJoin,Zerolink协议(盲签名)

Wasabi Wallet 的 CoinJoin 技术使用Zerolink协议切断输入交易和输出交易的关系,虽然完全去除了信任,但是由于需要调度服务器,仍然属于半中心化。核心技术就是盲签名、盲变换(一种同态加密,允许在密文上签名,逆盲变换签名对解密后明文依旧有效)。这类匿名化技术的模式我称之为“组团拼车模式”。 流程:

  1. 输入注册阶段:用户用tor身份Alice向服务器注册一次存款,内容包括要输入的UTXO,以及用这些UTXO的解锁私钥签名生成的input proof,明文找零地址,盲变换后的取款地址。
  2. 服务器检查一系列输入合法性,然后对盲取款地址进行签名,将签名返还给用户,并且给用户一个uniqueID。
  3. 输出注册阶段:用户换个全新tor身份Bob,把明文取款地址和解密的盲签名交给服务器。
  4. 签名阶段:用户换tor身份为Zlice,服务器生成一个多签名交易,他确认交易输入完整性,以及自己的输入输出都被包含在内,然后对交易签名。把上面的uniqueID、交易签名、输入的UXTO的index发回服务器。
  5. 广播阶段:交易被所有参与者签名,服务器开始广播此交易到链上。

Tornado.Cash原理源码解读

Tornado Cash 的业务逻辑如下:

存款

本地JS生成随机数secret和nullifier,然后生成一个deposit对象,其中有preimage、secret、nullifier、nullifierHash、commitment;调用主合约deposit函数,提交deposit对象并且发送对应数量的ETH;返回给用户一个用于提款的Note。 主合约Tornado.sol中,检查commitment是否提交过,如果没提交过,就插入Merkle Tree,更新Root;并且生成Deposit事件供外部JS访问。 MerkleTreeWithHistory.sol:链上的Merkle Tree合约只维护最新Root、Root历史记录(长度为100),当前commitment的下标index、用于更新Merkle Root的各层的FilledSubtree(从leaf到root路径上每个节点的兄弟节点的内容),也就是说链上的Merkle Tree只存在一系列增量信息。全量信息要靠JS访问区块链事件历史构建全量Merkle Tree。

提款

用户手里只有一个Note,Note中有什么信息呢?其实对于我们提款真正有用的只有Preimage(Nullifier + Secret)。 零知识证明过程证明的是:用户知道已经存储在区块链上的一个Commitment,并且知道其对应的Preimage,即其Hash = Commitment。 但是,我总不能直接告诉合约,我的Commitment是区块链上第多少多少号!这无异于自报家门,匿名性全无了。那怎么办呢?我们需要找到一种可以用零知识证明方法证明的方式,使得验证者合约相信我们的确有一个曾经提交到主合约、已经上链的Commitment。

zhuanlan.zhihu.com/p/40142647

这里就用到了其中“快速校验部分数据是否在整体数据中”的用途,部分数据就是我们的那一个Commitment,整体数据就是链上全体Commitment。而由MerkleTreeWithCommitment合约(主合约Tornado的父类)维护最新的100个Root(以防网络延迟)由合约层面确定了什么是“官方的、正确的整体数据的Root”,而用户要在本地构建一个完整的Merkle Tree,算出一个Root,先通过合约层面的Root检测,然后生成Merkle Proof(路径,以及补全的每一层路径另一侧子树的Hash,用来产生每个父节点的Hash),凭这些信息就可以证明我的Commitment确实出现在链上。

将Nullifier Secret Root以及上述Merkle Proof输入电路生成SnarkProof,用户或者Relayer发起交易付Gas费,调用主和约withdraw函数,同时附上Proof数据。

主和约确保Nullifier Hash是未出现过、未花过的,以及用户提供的Root确实是自己知道的代表完整记录的Root。 然后,主和约将自己验证过的输入Nullifier Hash 和 Root,以及用户方的输入Proof Recipient Fee等输入Verifier合约。这个合约能根据输入的Proof零知识证明以下几点:

  1. Merkle Proof提供的路径确实能联通用户提供的Nullifier和Secret生成的Commitment和提供的Root
  2. 用户提供的Nullifier、Secret确实对应这个Commitment Hash(用户是这个Commitment的拥有者) 同样因为零知识证明,合约并不具体知道用户提交的Merkle Proof和Secret。
  3. 因为Proof生成过程中,通过平方约束的方式锁定了Fee和Recipient,如果一个攻击者劫持这个Proof,然后告诉合约给自己发钱,并且尝试用高Fee挤掉其它交易,那么证明过程是不会通过的。

两者的区别

可以看出区别很大。

  1. CoinJoin为“组团拼车模式”,混币过程以服务器的调度为中心,以一次“组团”为基本单位,车上满之后服务器发车广播交易;Tornado Cash为“挂牌认领模式”,把用户提交的输入变成区块链上的一条记录commitment,用户通过零知识证明的方式证明自己在链上有有效挂牌,且知晓挂牌上的密文,但是他不需要告诉合约具体是哪个挂牌,不存在这种“组团发车”的过程。
  2. CoinJoin用到的盲签名技术,需要服务器持有私钥并主动进行盲签名,这对于区块链上的智能合约是不可能做到的。CoinJoin架构永远需要一个服务器作为主办方主持一次次组团过程,然后发车广播交易到链上;而Tornado Cash智能合约不需要服务器,只需要区块链数据库、智能合约以及与合约交互的DApp程序,所有密钥、Secret都只在用户手里。

**注:**近期Wasabi Wallet紧急修复DoS漏洞,这个漏洞允许攻击者零成本构造恶意交易,以此破解出调度服务器持有的盲签名私钥,自行生成签名后的明文地址;其他组团参与者付出了Input,却被冒名顶替,就会拒绝对交易签名,导致组团拼车失败。漏洞产生的根源就是一个本来应该严格一次一密的secret nonce被复用。

  1. 显然Tornado Cash在获得匿名性方面比CoinJoin更方便且更快捷,因为CoinJoin每次组团都只能获得一个较小的匿名集,而且混币只发生在组团交易发生时;但Tornado Cash混币每时每刻都发生:某个用户取币的时间是根本不确定的。一个观察者只能知道,某个存款者一定是在存款之后的某个时间里才取款,但是对于每次取款,你并不知道它对应之前的哪个存款,如果合约中存取款很活跃且平均,匿名集可能一直维持在一个较高水平。 注:匿名集可能因为短期内用户们只取不存而受到损害,试想这样一个情况:在一个1 ETH面额的合约中,用户们取款速度大于存款速度,终于有一天合约中只剩余1 ETH了,此时过往存款人次有5000次,如果某用户选择此时取出这1 ETH,他的这笔取款会拥有5000的匿名集;但他取出后,合约中ETH归零,匿名集也因此归零,因为接下来发生的任何取款都不可能对应这一时间点之前的存款;即使有用户继续存款,那某个取款对应此时间点之前的存款的概率也将变得极低,等于之前积累的匿名集的作用受到很大削弱。
  2. Tornado Cash 中所有存款commitment都占用链上空间,而且由于merkle tree深度限制为20,每个合约只能容纳一百万个存款,满后必须更换合约,原合约作废;CoinJoin不存在这个问题。

问题汇总

一:Tornado Cash是怎么防范重放、Forerun(冒名顶替)、双花的?

所有变量一旦输入电路生成了Proof就再也逆向不出来了。但是零知识Proof内部的变量能锁定外部变量使之必须一致,外部的变量也能锁定其内部的变量使之和外部的一致。

zhuanlan.zhihu.com/p/103530121 “公共输入和1”部分

这实际上是把一些变量从 prover 手中拿到 verifier 的手中,并同时保持等式相等。因而只有当 prover 和 verifier 的输入中使用相同值的时候, 有效计算检查才依然成立。

require(verifier.verifyProof(_proof, [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]), "Invalid withdraw proof");

零知识证明Proof实际捆绑了以下信息:用户侧生成的Root,用户提供的Nullifier Hash、用户选定的收款人地址、用户选定的Relayer、给Relayer的小费fee、(如果要提取ERC20 Token,还有要换取的ETH零钱数量refund,用来在未来付Gas费用,从而无需再用Relayer)。 合约确保了Nullifier Hash没有被使用过,确保了Root是自己产生的正牌Root。零知识证明的验证过程中又确保了用户提交的Nullifier Hash确实对应Proof生成时输入的Nullifier明文,用户不能在合约层提交一个假Nullifier,因为这会使得你无法通过零知识验证。(外部变量锁定里面变量) 同时这个零知识证明Proof还确保了合约接收到的明文(收款者、小费、Relayer地址、Refund找零)没有被篡改。因为这些信息在Proof生成过程中和Proof绑定了(内部变量锁定外部变量),如果有人篡改这些内容,那么验证将不会通过。Relayer也就不能收取高额小费,只能选择拒绝低小费的请求。

二:为什么JS、Circom、Solidity各有一个有关Merkle Tree的实现?

JS需要Merkle Tree来计算本地Root;Circom需要Merkle Tree来零知识证明、验证Merkle Proof,证明确实有一条从leaf到root的通路;Solidity维护的Merkle Tree只维护必要的增量信息,用来提供“什么是官方的完整的Root”以及更新Root。

三:为什么JS、Circom、Solidity各有一个zkSNARKs以及电路的实现?

JS:需要将用户输入输入电路,生成Proof;Circom是电路本身的源文件,能生成JS版本电路和一个Solidity合约版本验证代码;Solidity合约版本的Verifier是Circom源文件生成的,在链上证明Proof确实有效,负责为主和约返回结果。

Refund换钱机制

这个机制官方从未在面向用户的文章中提到过。 取款有两种方式,用户自己取款,或者付小费委托Relayer取款。取款就是发起交易就要用ETH付Gas费,如果用户用非匿名的ETH(如果你从别人那里私下买也算削弱了匿名性)付费,等于削弱自身匿名性。如果你存取的是ETH,那么使用一次Relayer之后你获得了匿名的ETH,此后就不需要Relayer了;但如果你是在存取ERC20 Token,委托Relayer并用Token来付小费(补偿Relayer花费的Gas),这个过程并不会给你ETH,用户还是不能独立发送取款交易,总是要依赖Relayer,怎么办? Tornado Cash主合约里ERC20取款实现存在一个Refund机制,就是由Relayer付Refund钱给合约,合约再把Refund交给收款地址,这样用户就能获得一部分ETH零钱,用以支付未来的交易Gas。当然,代码就是规则,发送某个交易是不是一笔赔本买卖需要Relayer自己计算清楚,如果用户支付的小费不足以覆盖Refund和Gas费以及自己的辛苦费,那么Relayer可以选择不干。

Circom个人理解:

所谓 Private Signal 和 Public Signal不是面向对象里的可见性,而是说 Private Signal 是只有Prover要给出的赋值,Public Signal 是 Verifier 也要输入以完成验证的那些赋值。 所谓“Signal 默认被看作是Private”也是指所有运算中产生的中间信号 Intermediate Signal 都是只有Prover要(在Witness中)给出的信号。只有在指定了Main函数(component)之后,Main 函数中所有的input/output signal,指定了Private的则都是private signal,没有指定的默认为Public(据我观察,就很少有人使用Public关键字)。 而一个Witness则包括所有Signal。只不过当我们使用Witness,配合Setup中的加密Proving Key生成一个零知识的Proof(只包含private signal的L R O 以及用全部变量生成的 h),那些Public Signal会另外以明文形式给出。 所以,Withdraw.circom 中用

 signal input recipient;//public
 signal recipientSquare; //intermediate, "private"
 recipientSquare <== recipient * recipient;//prevent optimizer removing

的方式,确保了recipient和Proof是绑定的,而验证合约能验证这一点。 这段代码本身是一个赋值+约束断言,不能用常规思路看待。其实它一点也不奇怪,只要电路里有一个约束,无论它具体是怎样的形式,都能验证内外一致性。Prover当然可以随便赋值一个数值就能使这条语句成立,但是此处的约束关系以及Public Signal的属性给了外部一个机会去验证他得到的明文是否和Prover生成Proof时用的一样。