零知识证明这个词这几年被用得太滥了,滥到很多人一听到就条件反射地想到"隐私""匿名""黑科技"。我最早接触它的时候也走过弯路,把它当成一种更高级的加密算法,以为加密完就完事了。后来在具体项目里被现实教育了几次才明白,它跟加密压根是两码事:加密关心的是"别人看不见内容",零知识证明关心的是"我说的话你信,但我什么额外的东西都没告诉你"。这两件事的目标完全不同,工程上的实现手段也完全不同。
这篇东西想讲清楚的是:零知识证明这套机制到底由哪几块拼起来,每块为什么必须那样设计,以及在真实工程里怎么从零把一个可运行的证明系统跑起来。从最基础的三个性质、Sigma 三段式协议,到非交互化、算术化、约束系统,再到参数选择、性能实测和一堆踩过的坑,我都会摊开讲。适合两类人看:一类是刚把密码学基础课学完、想知道课本上的定义怎么变成能跑代码的人;另一类是工程里被要求"加个零知识证明"、但不确定这东西到底能干什么不能干什么的人。看完你至少能做到三件事:判断一个场景是不是真的需要它、看懂一份证明系统的参数表、自己写出一个跑得通的教学级实现。
阅读过程中如果有数学公式看着眼晕,可以先跳过推导,只看结论和代码,回头再补。零知识证明的门槛不在于数学有多深,而在于层层抽象叠起来之后,人容易在半路丢掉"我们现在到底在保证什么"这条主线。
1. 先把概念钉死:零知识证明到底在保证什么
1.1 三个性质是硬门槛,缺一个就不是零知识证明
任何一套被称为零知识证明的协议,必须同时满足三条性质,这三条不是可选项,是定义的一部分。
完备性(Completeness):如果陈述是真的,证明者诚实地按协议执行,验证者一定会被说服。这条最朴素,本质上是"协议不能把好人冤枉了"。工程上这条通常最容易满足,但也最容易在优化过程中被无意破坏,比如某个边界情况下承诺值和响应值刚好落在特殊点上,验证等式不成立。
可靠性(Soundness):如果陈述是假的,任何证明者都没法让验证者接受,除非他以极小概率蒙对。严格的表述里还有"知识可靠性"这个强化版本,意思不只是"你证明了这个陈述为真",而是"你确实知道一个能让它为真的证据",这两者在某些构造下是有区别的。工程上看可靠性,看的是可靠性误差这个数字,比如 2^-128,它直接决定你的协议要跑多少轮、挑战空间要多大。
零知识性(Zero-Knowledge):验证者在整个交互结束后,除了"陈述为真"这件事本身,学不到任何额外信息。这条在数学上的标准证法是构造一个模拟器——这个模拟器不掌握证据,却能在多项式时间内生成与真实交互不可区分的对话记录。能构造出模拟器,就说明验证者看到的那堆消息里没有承载证据信息,因为如果承载了,一个不知道证据的模拟器不可能伪造出来。
这三条的关系很微妙。很多人一开始只盯着第三条,觉得"零知识"是重点,实际上可靠性才是安全性的根基,零知识只是隐私属性。一个只满足零知识不满足可靠性的协议,等于一个人可以随口编造任何结论而你无法反驳,隐私是隐私了,但没有意义。
我见过一个挺典型的误解:有人把"证明过程不泄露原始数据"当成了零知识证明的全部,于是设计了一个方案——把数据哈希后公布哈希值,然后声称"我证明了我知道原像"。这确实零知识(哈希值不泄露原像),但可靠性只在抗碰撞假设下成立,而且它证明的只是"我知道某个原像",没法证明"这个原像满足某组业务规则"。搞清楚你要证明的陈述是什么,比选什么算法重要得多。
1.2 洞穴直觉:一个能讲明白零知识、但讲不明白可靠性的类比
最经典的零知识类比是洞穴故事。一个环形洞穴有两个出口,左边和右边,中间有一道需要咒语才能打开的门。证明者声称自己会咒语,验证者想验证但不想学会咒语。流程是:验证者站在洞外,让证明者随便从一个出口进去,然后验证者随机喊"从左出"或"从右出"。如果证明者真会咒语,他每次都能按要求出来;如果他不会,他只有在恰好从被指定的那个出口进去时才蒙对,概率二分之一。重复二十轮,蒙对的概率就是百万分之一。
这个类比把零知识性讲得很透:验证者看到的只是"证明者从某个出口走出来"这个事实,从中学不到咒语本身。但如果有人问"为什么证明者不能提前在洞里等着、从两边来回跑",就会发现问题——真正让这个协议成立的前提是,证明者在选择出口之前不能预知验证者会喊哪边,也就是验证者的挑战必须是真随机的。
这个类比的局限在于它讲不了可靠性。它靠的是概率收敛,而现代证明系统的可靠性往往来自代数结构,比如多项式承诺的绑定性质,跟"猜硬币"是完全不同的机制。所以我一般建议:洞穴故事用来给非技术同事建立直觉,讲完就立刻补一句"真实系统里保证可靠性的手段完全不一样,靠的是数学结构不是概率游戏",否则后面沟通会出问题。
1.3 零知识不等于零信息:几个容易被忽略的泄露面
严格意义上的零知识,是相对某个特定验证者的视图定义的。验证者的视图包括他收到的所有消息、他自己产生的随机数、以及他能从中计算出的任何东西。这个定义之外的东西,一概不在保护范围内。
实际工程里,下面的东西经常被漏掉:
| 泄露面 | 具体表现 | 缓解思路 |
|---|---|---|
| 公开输入 | 你要证明"余额大于零",证明本身不泄露余额,但余额区间可能通过公开输入暴露 | 拆分公开输入,只公开必要的承诺值 |
| 证明生成耗时 | 约束多、见证大的证明,生成时间明显更长,可能反推出数据规模档次 | 固定批次、填充到固定规模 |
| 证明大小 | 某些方案证明大小随数据量变化 | 选固定大小的方案,或做填充 |
| 元数据 | 谁在什么时候提交了证明,本身就是信息 | 批量提交、混入匿名集 |
| 重复提交模式 | 同一组见证反复提交,模式可能被识别 | 加入随机化,避免确定性见证 |
我个人的判断标准是:先画一张表,列出"验证者最终能拿到的东西",再逐项问"这一项能不能反推出我不希望他知道的信息"。这张表画不出来,说明你还没想清楚要保护什么。
还有一点:零知识证明保护的是"证明这个动作",保护不了"数据本身存得对不对"。如果原始数据存在一个能被拖库的地方,证明做得再漂亮也没用。安全是个木桶,零知识证明只是其中一块板。
2. 方案选型:为什么真实系统都长成现在这个样子
2.1 交互式协议必须变成一封信,否则工程上根本没法用
教科书上的零知识证明大多是交互式的:证明者发一个承诺,验证者回一个随机挑战,证明者再回一个响应,来回若干轮。这个模型在理论分析上很干净,但放到工程里几乎没法落地。
原因很实在。第一,验证者必须在线,而且要在证明者提交之后实时产生随机挑战,这在分布式系统里意味着同步开销和可用性依赖。第二,交互过程不可重放,一份证明只能给一个验证者看,如果你想把这个证明贴到公开网络上让所有人验证,交互式协议做不到。第三,验证者的随机性必须真随机且不可预测,一旦验证者被攻破或偷懒用了弱随机数,整个协议的安全性直接崩掉。
解决办法是把验证者的随机挑战换成哈希函数。具体做法是:把证明者之前发出的所有消息,加上所有公开参数,一起哈希,哈希结果当作挑战值。这样挑战就变成了"证明者自己算出来的、但他无法预测也无法操控的值"。这个变换通常被称为哈希挑战变换,在文献里更常见的叫法是 Fiat-Shamir 变换。
这个变换的安全性依赖一个很强的假设:哈希函数表现得像一个随机预言机,也就是对任何没被查询过的输入,输出是不可预测且均匀分布的。真实哈希函数当然不是随机预言机,这个 gap 是零知识证明领域被讨论最多的话题之一。工程上的应对是:选用经过充分分析、抗碰撞性和抗原像性都够强的哈希函数,并且把所有该绑定的东西都绑进哈希输入。
绑定的完整性问题非常现实。如果你哈希的时候漏掉了某个公开参数,攻击者就可能通过修改这个参数构造出能通过验证的假证明。业界出过类似的审计发现,症状是"同一份证明换个验证合约地址也能通过"。所以我在实现里会强制要求哈希输入包含:协议版本号、域参数、所有公开输入、所有承诺值。宁可多哈希几十个字节,也不要漏。
2.2 可信设置还是透明设置:那笔一次性成本怎么算
选型时绕不开的一个岔路口是:方案需不需要可信设置。
可信设置的意思是,系统初始化时要生成一组公共参数,生成过程中会产生一些"有毒废料",这堆废料如果被任何人保留下来,他就能伪造任意证明。所以初始化必须由多方参与、每人贡献一部分随机性、最后只要有一方诚实地销毁了自己的那部分,整体就是安全的。这个多方仪式在工程上很麻烦:参与方要跨组织协调、要提供可验证的计算证明、要有公开的审计记录。
透明设置则不需要这一步,公共参数直接从公开的随机种子推导出来,谁都能验证参数没被做手脚。代价是证明体积更大、验证开销更高,通常是前者的几倍到几十倍。
我的经验是,这个决策不能用"哪个更先进"来判断,得看场景:
| 场景特征 | 建议方向 | 理由 |
|---|---|---|
| 证明会被上链、每次验证都要付费 | 倾向可信设置 + 小证明 | 验证开销直接换算成成本,长期看省下的费用远超一次性仪式成本 |
| 单一组织内部系统、参数生成方可控 | 可信设置可行 | 仪式只需要在组织内部完成,协调成本低 |
| 需要多方共同治理、参数必须公开可验证 | 倾向透明设置 | 没人愿意相信别人办的仪式 |
| 证明体积不敏感、验证次数少 | 透明设置更划算 | 省掉仪式的心智负担和合规成本 |
| 有后量子要求 | 倾向基于哈希的透明方案 | 基于配对的方案在后量子场景下的安全性存疑 |
顺带说个容易被忽略的点:可信设置的仪式不是做完就完事了,你得保留完整的验证材料,让任何第三方都能验证参数确实是多方参与生成的。如果只保留最终参数、丢掉过程记录,那这个设置实际上退化成了"单方生成",等于没有。
2.3 三条主流技术路线的横向对比
抛开具体实现名,市面上能落地的方案大致分三条路线,差异主要来自它们用什么数学工具做"承诺"这一步。
| 维度 | 配对型方案 | 内积论证型方案 | 哈希型方案 |
|---|---|---|---|
| 核心工具 | 椭圆曲线配对 | 向量内积承诺 | 哈希树与低度测试 |
| 证明大小 | 常数级,几百字节 | 对数级,随电路增大而增长 | 几十到几百 KB,明显更大 |
| 验证开销 | 少量配对运算,毫秒级 | 多标量乘,几十毫秒 | 纯哈希运算,很快 |
| 证明生成开销 | 较高,受配对和 FFT 影响 | 中等 | 较高,但可并行度好 |
| 是否需要可信设置 | 需要 | 通常不需要 | 不需要 |
| 后量子安全 | 不具备 | 不具备 | 相对更有优势 |
| 适合的场景 | 链上验证、证明体积敏感 | 中等规模、需要透明性 | 大规模计算、有后量子要求 |
三条路线没有绝对优劣,本质是在证明体积、验证开销、生成开销、可信假设四者之间做取舍。我在实际选型时的顺序是:先确定有没有链上验证的硬约束(有的话基本就锁死配对型或内积型),再确定能不能接受可信设置,最后才比性能。
2.4 安全性假设决定了这套东西十年后还能不能用
选型时还有一个常被忽略的维度:方案的安全性建立在什么数学难题上。
配对型方案的安全性依赖椭圆曲线上的计算难题,而这类难题在足够强的量子算法面前是不成立的。也就是说,今天生成的证明,理论上存在被未来算力攻破的可能——注意这里说的是伪造证明的可能性,不是已发出证明的隐私被破解,因为零知识性通常不依赖这个假设。但如果你要做的系统生命周期是二三十年(比如某些存证类应用),这个风险就得纳入考量。
哈希型方案的安全性主要依赖哈希函数的抗碰撞性和抗第二原像性,这类假设被认为对量子算法更友好(量子算法对哈希的加速是平方根级别,加大输出长度就能抵消)。所以如果你的场景有长期安全要求,哈希型是更稳的选择,代价是证明体积大。
还有一个中间地带的考量:参数的位宽。行业里普遍以 128 位安全强度为目标,对应的椭圆曲线通常用 256 位左右的基域,素域也是 256 位上下。如果你的数据要处理 256 位整数,注意电路的字段和数据的位宽要匹配,否则会引入溢出风险——这个坑在第 5 节会详细讲。
3. 核心组件拆解:一句话是怎么变成一堆约束的
3.1 有限域与群运算:所有花活的落脚点
所有零知识证明的计算都发生在有限域上。有限域简单说就是一个元素个数有限的集合,加减乘除都在这个集合内封闭,除零除外。最常用的是素域,元素就是 0 到 p-1 的整数,运算结果对 p 取模。
为什么非得用有限域?因为证明系统要处理的是精确的等式关系,浮点数那种"差不多相等"在这里完全不可接受。你在域上验证 a*b=c,要么精确相等,要么不等,没有中间状态。这带来的直接好处是:整个证明过程可以被精确地数学分析,可靠性误差能被严格计算出来。
工程上要区分两个域:标量域和基域。标量域是约束系统里变量取值所在的域,通常是一个 256 位左右的素域;基域是椭圆曲线坐标所在的域,用于承诺和配对。很多实现里两者大小接近但不相等,混用会导致微妙的错误。
选域时我会看两个参数:
- 位宽:一般 255 到 256 位,要能放下你电路里最宽的整数运算,同时留出余量做中间结果。
- 2-adicity:这个数表示 p-1 能被 2 的多少次幂整除,它直接决定了多项式插值和快速傅里叶变换能切多细。2-adicity 太小,你的电路规模一大就做不了高效的 FFT,证明生成会慢得离谱。
我的经验是:如果你的电路规模会超过百万级约束,选域的时候一定要把 2-adicity 作为一个硬指标看,别只看位宽。有些实现为了兼容已有曲线选了 2-adicity 只有 20 出头的域,结果电路一大就卡在 FFT 上。
3.2 承诺方案:把秘密装进一个不能改口的信封
承诺是零知识证明的基本积木。它的功能是:证明者先把一个值"封"起来给验证者,此时验证者看不到内容(隐藏性),但证明者之后也没法换一个值来冒充(绑定性)。
最朴素的承诺是哈希承诺:把值和一个随机盐拼起来做哈希,公布哈希值。验证者看不到原值,证明者也没法找另一个值凑出同样的哈希(依赖抗碰撞性)。简单、便宜、不需要任何数学假设之外的东西。
但哈希承诺有个致命缺陷:它不满足同态性。也就是说,你没法从两个承诺值直接算出它们对应值之和的承诺。而零知识证明的绝大部分工作量,恰恰是在验证一堆线性关系——比如"输入之和等于输出之和"。如果每次验证线性关系都要打开承诺,那隐私就没了。
所以主流的证明系统用的是代数承诺,最典型的是双生成元承诺:公开两个群元素 g 和 h,承诺值 = g^v * h^r,其中 v 是要承诺的值,r 是随机盲化因子。这个构造同时满足隐藏性(因为 r 随机,v 被遮住了)和绑定性(想换 v 就得解离散对数),而且满足加法同态:两个承诺相乘,等于对应值相加的承诺。这一条是整个证明系统能高效验证线性约束的关键。
后来发展出的向量承诺,比如基于内积的构造,进一步支持"承诺一个向量,然后证明这个向量和某个公开向量的内积等于某值",这直接对应了电路约束里最常见的乘法门检查。用哪个承诺方案,基本就决定了你走哪条技术路线。
一个实操细节:承诺里的盲化因子必须每次重新随机生成。如果同一个值用同一个盲化因子承诺两次,两个承诺值的比值就直接暴露了原值的关系。更严重的是,如果盲化因子在多个承诺间复用,攻击者可以通过解方程组反推出盲化因子,进而反推所有被承诺的值。这个错误在一些手写的教学实现里很常见,因为它不会导致验证失败,只会静默泄露。
3.3 算术化:把程序翻译成多项式的那道工序
零知识证明不能直接证明"这个程序算对了"。它只能证明"我知道一组数,使得某组代数方程成立"。所以中间必须有一道翻译工序,把程序逻辑变成代数方程,这道工序叫算术化。
最常用的中间表示是秩一约束系统。它的形式非常简洁:每条约束形如
(Σ a_i * w_i) * (Σ b_i * w_i) = (Σ c_i * w_i)其中 w 是向量,包含了所有私有输入(见证)和公开输入,a、b、c 是常数系数向量。翻译看起来吓人,其实很直观:每条约束描述一个乘法门,左边两个线性组合相乘,等于右边的线性组合。加法不用单独成约束,因为它可以合并进线性组合里。
举个例子,要证明"我知道 x 使得 x³ + x + 5 = 35"。这个等式里有两次乘法,所以要拆成中间变量:
s1 = x * x s2 = s1 * x out = s2 + x + 5三条约束,前两条各是一个乘法门,第三条是纯线性关系,可以合并到公开输入的检查里。见证向量就是 (1, out, x, s1, s2),其中 1 是常数项占位。
写成约束形式:
| 约束编号 | 左线性组合 | 右线性组合 | 结果 |
|---|---|---|---|
| 1 | x | x | s1 |
| 2 | s1 | x | s2 |
| 3 | s2 + x + 5 | 常数 1 | out |
把这个系统再往前推一步,用拉格朗日插值把每条约束的系数变成多项式在某个点上的取值,就得到了多项式形式的约束系统。接下来证明者要做的是:对这些多项式做承诺,然后利用"两个多项式在随机点上相等则大概率恒等"这个性质,让验证者只检查一个点。这一步把"检查 N 条约束"压缩成了"检查一次多项式求值",是整个技术的关键跃迁。
算术化这一步是最容易出错的地方,也是我建议新手一定要自己手写一遍的地方。因为编译器不会告诉你"你少写了一条约束",它只会给你一个能验证通过的证明,但这个证明证明的东西跟你想证明的东西不是一回事。
3.4 三段式骨架:承诺、挑战、检验
抛开所有复杂度,任何零知识证明协议的骨架都是三段:
- 承诺阶段:证明者把所有中间值用承诺方案封起来,发给验证者。
- 挑战阶段:验证者(或哈希函数)产生一组随机挑战值。
- 响应与检验阶段:证明者用挑战值构造响应,验证者检查一组等式。
这个骨架之所以能同时保证可靠性和零知识,靠的是一个叫提取器的思想实验:假设存在一个能对同一组承诺给出两组不同挑战下都合法的响应的证明者,那么从这两组响应里可以反解出证据。这就意味着,能通过验证的证明者必然"知道"证据,否则他不可能同时应付两个不同方向的挑战。
反过来,零知识性靠的是模拟器:模拟器不掌握证据,但它可以先"猜"一个挑战值,然后倒着构造出响应和承诺。因为挑战空间很大,猜中的概率低,但模拟器不被限制在多项式时间内只能跑一次,它可以在思想实验里不断重试。这个论证在数学上成立,就说明真实对话里没有承载证据信息。
这两条论证是理解所有零知识证明协议的总钥匙。后面不管看到多复杂的构造,你都可以问两个问题:提取器怎么构造?模拟器怎么构造?答不上来,这个协议要么有安全性问题,要么你还没理解它。
4. 动手实现:从离散对数协议到可验证约束
4.1 环境准备与参数选择
这一段用 Python 做演示,标准库就够了,不需要装任何第三方包。选 Python 是因为它的大整数运算没有溢出问题,写密码学演示最省心。生产环境当然不会用 Python 写证明系统,但教学和理解原理,没有比它更合适的。
先定参数。我选 p = 2²⁵⁵ - 19,这是一个常用的素数,位宽 255 位,够教学用。生成元 G 取 5,注意严格来说 5 未必是群的生成元,但它的阶足够大,用于演示没问题。真实系统里生成元的选择要经过严格验证,不能随手取。
import hashlib import secrets P = 2**255 - 19 G = 5 def keygen(): """生成私钥 x 和公钥 y = G^x mod P""" x = secrets.randbelow(P - 2) + 1 y = pow(G, x, P) return x, y为什么要用secrets而不是random?因为random模块的随机数来自梅森旋转算法,是可预测的。密码学场景下所有随机数必须来自操作系统的密码学安全随机源,Python 里就是secrets模块。我在代码审计里见过不止一次有人图省事用random.randint,后果是私钥可以被直接推出来。
4.2 交互式版本:把对话完整跑通
这是一个标准的离散对数 Sigma 协议,证明的是"我知道 x,使得 y = G^x"。整个流程三轮:
def prover_commit(): """第一步:证明者选随机数 k,计算承诺 t""" k = secrets.randbelow(P - 2) + 1 t = pow(G, k, P) return k, t def prover_respond(k, x, c): """第三步:证明者用挑战 c 计算响应 s""" return (k + c * x) % (P - 1) def verifier_check(y, t, c, s): """验证者检查等式 G^s == t * y^c""" left = pow(G, s, P) right = (t * pow(y, c, P)) % P return left == right跑一遍:
x, y = keygen() k, t = prover_commit() c = secrets.randbelow(P - 2) + 1 # 交互式场景下由验证者产生 s = prover_respond(k, x, c) assert verifier_check(y, t, c, s)验证等式为什么成立?因为左边是 G^(k+cx),右边是 G^k * (G^x)^c = G^(k+cx),两边相等。这个推导只用到了指数运算的基本性质,没有任何额外假设。
这个协议的精妙之处在于它同时满足三条性质,而且证明都很短:
- 完备性:诚实的双方一定通过,就是上面那个等式。
- 特殊可靠性:如果同一个承诺 t 对两个不同挑战 c₁、c₂ 都给出了合法响应 s₁ = k + c₁x、s₂ = k + c₂x,那么两式相减得 s₁ - s₂ = (c₁ - c₂)x,于是 x = (s₁ - s₂) * (c₁ - c₂)⁻¹。也就是说,能对两个方向都答对的人,必然掌握 x。
- 特殊零知识性:模拟器可以先随机选 c 和 s,然后倒算 t = G^s * y^(-c)。这样得到的 (t, c, s) 三元组在验证者看来和真实对话完全同分布。
注意最后这条的措辞:是"对诚实验证者零知识",因为模拟器预先决定了 c,只有当验证者的挑战是随机的、模拟器猜中的分布才和真实一致。如果验证者是恶意的、专门挑 c,模拟器就没法保证分布相同。这个区分在理论上是必要的,工程上则通过"验证者的挑战必须是真随机或哈希产生"来兜底。
4.3 非交互化:用哈希挑战替换验证者的嘴
把上面三段压成一封信,改动只有一处:挑战值不再由验证者给,而是由证明者自己哈希出来。
def _h(*vals): h = hashlib.sha256() h.update(b"zkp-demo-v1") # 域分隔符,防止跨协议重放 h.update(P.to_bytes(32, "big")) # 绑定参数 h.update(G.to_bytes(32, "big")) for v in vals: h.update(int(v).to_bytes(32, "big")) return int.from_bytes(h.digest(), "big") def prove(x): y = pow(G, x, P) k = secrets.randbelow(P - 2) + 1 t = pow(G, k, P) c = _h(y, t) # 挑战由哈希产生 s = (k + c * x) % (P - 1) return (y, t, s, c) def verify(proof): y, t, s, c = proof if _h(y, t) != c: # 重算挑战,检查一致性 return False return pow(G, s, P) == (t * pow(y, c, P)) % P三处细节值得单独说。
第一处是域分隔符。我在哈希输入最前面塞了一个字符串b"zkp-demo-v1"。这不是可有可无的装饰。如果两个不同的协议用同样的哈希函数、同样的输入格式,就可能出现跨协议的攻击:某人在协议 A 里的合法证明,被拿到协议 B 里当有效证明用。加个域分隔符,成本几乎为零,收益是杜绝一整类重放问题。
第二处是参数的绑定。P 和 G 都进了哈希输入。如果漏掉它们,攻击者可能构造出在另一组参数下有效的证明,然后声称它在你这里也有效。所有公开参数、所有公开输入、所有承诺值,一个都不能少。
第三处是int(v).to_bytes(32, "big")这个固定长度编码。这里必须固定长度,不能用变长编码。如果长度可变,就会出现"不同输入拼出相同字节串"的歧义,攻击者可以利用这个歧义构造哈希碰撞。这类问题在序列化层面非常隐蔽,一般审计工具查不出来,只能靠代码规范约束。
4.4 模拟器实验:亲手验证零知识不是喊口号
前面说模拟器能在不知道 x 的情况下造出"看起来像真的"对话。这个说法值得动手验证一次,因为它是理解零知识性最直接的方式。
def simulate(y): """模拟器:不知道 x,也能造出合法的对话三元组""" c = secrets.randbelow(P - 2) + 1 s = secrets.randbelow(P - 2) + 1 # 倒算 t,使得验证等式成立 t = (pow(G, s, P) * pow(pow(y, c, P), -1, P)) % P # 检查验证等式确实成立 assert pow(G, s, P) == (t * pow(y, c, P)) % P return (t, c, s)跑一遍会发现断言一定通过——模拟器造出来的三元组确实满足验证等式,尽管它根本不知道 x。
那为什么这种伪造不能直接当证明用?因为非交互场景下,c 必须是哈希算出来的。模拟器是先选 c 再倒算 t,此时算出来的_h(y, t)几乎不可能等于 c。也就是说,verify函数里的第一道检查会把模拟器的产物直接拦下。
这个对比非常漂亮地解释了非交互化的代价和收益:交互式协议里,验证者看到的东西和模拟器造的东西无法区分,所以是零知识的;但一旦用哈希把挑战固定下来,模拟器就再也造不出能通过检查的东西,所以是不可伪造的。同一个哈希函数,一边提供零知识性,一边提供可靠性,这是整个技术里最优雅的地方。
这里有个练习建议:把
simulate改造成"先算 t 再算 c,然后伪造 s",你会发现无论怎么尝试都通不过验证,除非你能解离散对数。这个失败尝试比任何证明都更能说服人。
4.5 从协议到电路:一个三次方程的可满足性
前面的例子只证明了"我知道一个离散对数",信息量太小。真实场景要证明的是"我知道一组输入,使得某段计算的结果等于某个公开值"。这就进到约束系统了。
拿前面那个 x³ + x + 5 = 35 举例,先写一个朴素的检查函数:
def check_witness(x, public_out=35): s1 = x * x s2 = s1 * x out = s2 + x + 5 return out == public_out, {"s1": s1, "s2": s2, "out": out} ok, witness = check_witness(3) assert ok and witness["out"] == 35这个函数能验证,但它是"打开"验证——验证者知道了 x 是 3。要变成零知识,得把这套计算翻译成约束,然后对约束里的每个值做承诺。
我把约束系统写成一个显式的结构,方便对照:
# 约束系统:每条 (a, b, c) 表示 (a·w) * (b·w) = (c·w) # 见证向量 w = [1, out, x, s1, s2] # 索引 0 1 2 3 4 CONSTRAINTS = [ # s1 = x * x ({"2": 1}, {"2": 1}, {"3": 1}), # s2 = s1 * x ({"3": 1}, {"2": 1}, {"4": 1}), # out = s2 + x + 5 ({"4": 1, "2": 1, "0": 5}, {"0": 1}, {"1": 1}), ] def eval_linear(coeffs, w): return sum(coeff * w[int(idx)] for idx, coeff in coeffs.items()) def check_constraints(w): for a, b, c in CONSTRAINTS: if eval_linear(a, w) * eval_linear(b, w) != eval_linear(c, w): return False return True w = [1, 35, 3, 9, 27] assert check_constraints(w)这段代码的意义在于:它把"算一遍"变成了"检查一组代数关系"。真实的证明系统做的事情是——证明者把 w 里除了公开部分之外的每个元素都承诺起来,然后用多线性扩展或者拉格朗日插值把这三条约束变成多项式,再通过随机点求值把三条检查压缩成一次检查。所有这些复杂操作,目标只有一个:让验证者在不看到 w 的情况下,确信这三条约束都成立。
我强烈建议每个想深入这个领域的人都手写一遍这个小例子。因为第 5 节要讲的"欠约束"问题,在这里就能直观感受到:如果你漏写一条约束,比如把第三条改成只有out = s2,那么一个不知道正确 x 的人就能随便挑一组数糊弄过去,而程序不会有任何报错。
4.6 复杂度与参数实测记录
我在一台普通笔记本(4 核,16G 内存)上跑了几组对照,记录一下量级,供参考:
| 规模(约束数) | 承诺阶段耗时 | 证明生成总耗时 | 证明大小 | 验证耗时 |
|---|---|---|---|---|
| 1 万 | 约 20 毫秒 | 约 300 毫秒 | 几百字节 | 约 5 毫秒 |
| 10 万 | 约 200 毫秒 | 约 3 秒 | 几百字节 | 约 5 毫秒 |
| 100 万 | 约 2 秒 | 约 40 秒 | 几百字节 | 约 6 毫秒 |
| 1000 万 | 约 25 秒 | 约 8 分钟 | 几百字节 | 约 8 毫秒 |
几个观察:
证明生成耗时基本随约束数线性增长,但有个明显的墙。规模过了 100 万之后,内存占用增长得比时间快得多,因为 FFT 需要多份多项式同时驻留内存。我实测 1000 万约束时峰值内存接近 12G。所以选机器的时候,内存比 CPU 更重要。
验证耗时几乎不随规模变化。这是这类方案最值钱的特性:不管你在链下算了多少东西,链上验证的成本是常数。这也是批处理和二层扩容方案能成立的根本原因。
证明大小是常数。上面表格里我写"几百字节",是因为配对型方案的证明大小由群元素个数决定,跟电路规模无关。这个特性对链上存储非常友好。
承诺阶段的耗时占比很小。大头在多项式构造和 FFT,这两个环节都能并行化。如果你的场景对证明生成时间敏感,优先优化这两块,而不是去抠承诺方案的常数。
一个实测心得:把约束数凑成 2 的整数次幂能让 FFT 效率明显提升。我试过 100 万零 1 条约束和 2^20 条约束,后者反而更快,因为前者要补零到 2^21 才能做 FFT。所以设计电路时尽量让规模对齐到 2 的幂,宁可多几条填充约束。
5. 实战踩坑与排查清单
5.1 欠约束:最致命也最隐蔽的一类错误
欠约束指的是约束系统没有完全锁死见证向量的所有自由度,导致存在多组不同的见证都能满足所有约束,其中只有一组是"正确"的。攻击者可以利用这些自由度,构造出满足约束但语义错误的证明。
举个具体例子。假设你要证明"我知道 x 使得 x² = 9",约束写成x * x = 9。这条约束有两个解 3 和 -3。如果你的业务逻辑要求 x 必须为正,但约束里没写这个条件,那么 -3 也能通过验证。这就是典型的欠约束。
更隐蔽的情况出现在变量没有被任何约束引用的时候。比如你引入了一个中间变量tmp,本来想用它做范围检查,但后来重构代码时把检查删了,tmp变成了自由变量。约束系统依然能生成证明、依然能通过验证,但证明的内容已经变得毫无意义。
排查这类问题,我用的方法是自由度审计:数一数见证向量的长度,减去约束提供的独立方程数量,看剩下的自由度是不是符合预期。如果一个电路里所有中间变量都应该被唯一确定,那么剩余自由度应该是 0。这个数字对不上,就说明有变量没被锁住。
另一个方法更直接:故意给一个错误的见证,看约束检查能不能抓住。写一组单元测试,往电路里塞各种边界值——0、1、域的上界、负数、超范围的数,看哪些能通过。能通过的但语义上不该通过的,就是欠约束点。这个方法土但有效,我在每个电路上都会跑一遍。
5.2 随机数复用与"有毒废料"
随机数复用是密码学里的经典事故。在前面那个离散对数协议里,如果证明者用同一个 k 生成了两个承诺,只是挑战不同,那么攻击者拿到 (t, c₁, s₁) 和 (t, c₂, s₂) 两组数据,直接算 x = (s₁ - s₂) * (c₁ - c₂)⁻¹ 就把私钥提出来了。这个推导我在 4.2 节已经写过,它就是"特殊可靠性"的证明本身——可靠性证明和攻击方法是同一个式子,这在密码学里很常见。
在更复杂的证明系统里,随机数复用导致的泄露不一定是"直接解出私钥",但通常会导致见证的部分泄露。比如多项式承诺里的盲化因子如果复用,攻击者可以消去盲化项,得到关于见证的线性方程。积累几个方程就能解出整个见证。
工程上的防范手段很朴素:所有随机数都从密码学安全的随机源取,绝不复用,绝不用固定种子。听起来简单,但在我见过的代码里,用固定种子做"可复现测试"然后忘了改回来的情况,出现过不止一次。我的做法是把随机源做成可注入的依赖,测试环境用确定性随机源,但生产代码里根本不提供这个入口,从结构上杜绝误用。
"有毒废料"是另一类问题。可信设置生成参数时会产生一些中间值,这些值如果被保留,持有者能伪造任意证明。所以仪式结束后必须销毁,而且要有可验证的证据表明销毁发生了。有些团队为了"以后方便调试",把废料备份了一份放在内部服务器上,这就等于把整个系统的安全性押在那台服务器的安全上。我见过的做法里,比较稳妥的是用多方计算生成,参与方各自销毁自己那份,最后只要有一方诚实,整体安全。
5.3 验证失败的六种典型症状对照表
证明验证不通过是最常见的求助场景。我把遇到过的情况整理成一张表,按排查顺序排列:
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 哈希挑战不一致 | 哈希输入漏了参数,或序列化长度不固定 | 把哈希输入逐字节打印出来对比,确认字段顺序和长度 |
| 挑战一致但等式不成立 | 域元素溢出,计算时没取模 | 检查所有运算是否在正确的域内进行 |
| 公开输入顺序错乱 | 电路里公开输入的索引和外部传入顺序不匹配 | 打印见证向量的公开部分,和外部输入逐项对照 |
| 小规模通过、大规模失败 | FFT 补零逻辑错误,或 2-adicity 不够 | 换成 2 的整数次幂规模测试 |
| 本地通过、跨环境失败 | 版本不一致,参数或哈希域分隔符不同 | 锁定依赖版本,把域分隔符加入配置校验 |
| 偶发失败 | 随机数生成有竞态,或线程共享了状态 | 检查随机源是否被多线程共享 |
排查时我的习惯是从最外层往里收:先确认哈希挑战是否一致(这能排除一半问题),再确认验证等式(能排除域运算问题),最后才去查电路约束。这个顺序能避免一上来就钻进电路细节里出不来。
还有一个经验:永远保留一份"最小可复现用例"。把出问题的电路缩减到三五条约束,如果问题还在,定位起来会快得多;如果问题消失了,那说明是规模触发的,直接往 2-adicity 和内存方向查。
5.4 性能与内存的优化抓手
证明生成慢是另一个高频问题。按收益从高到低排,我会依次尝试这几件事:
减少约束数量。这是最根本的优化。同样一段逻辑,写法不同约束数能差好几倍。比如范围检查,用逐位分解的方式要 256 条约束,改用查表方案可能只要几条。再比如批量验证多个哈希,能用一次约束搞定就别拆成多次。约束数每降一半,证明时间基本也降一半。
对齐到 2 的幂。前面提过,FFT 对规模敏感,凑整能省下不少时间。
并行化多项式运算。FFT 和多项式乘法都可以按块并行,多核机器的利用率能拉到接近线性。注意承诺阶段的群运算并行度没那么高,别指望它成为优化重点。
用递归或聚合减少验证次数。如果场景是"验证一万个证明",不要真的一万次验证,而是把这批证明聚合成一个,验证一次。这个技术的实现复杂度不低,但收益是数量级的。
内存优先于 CPU。大规模证明的瓶颈几乎总是内存。我做过一次对比,同样是 500 万约束,把内存从 16G 加到 64G,总耗时降了将近四成,因为避免了换页。选机器时按"约束数 × 每约束约 2KB"来估内存需求,再留一倍余量。
6. 场景落地:哪些地方真的需要它,哪些地方是硬套
6.1 隐私转账里的"账要对得上"
这是最经典的应用。需求是:证明一笔转账合法,但不公开金额和账户余额。合法性要满足几个条件——输入金额之和等于输出金额之和(不能凭空造钱)、每个金额都在合法范围内(不能是负数或者溢出)、签名有效。
用普通手段,验证者要看到所有金额才能检查这些条件。用零知识证明,证明者可以构造一个证明,声明"存在一组金额和盲化因子,使得承诺值满足守恒关系,且每个金额落在 [0, 2^64) 内"。验证者只看到承诺值和证明,检查通过就相信了。
这里的难点在范围证明。金额是一串比特,要在不泄露的情况下证明它没有超出范围,最朴素的做法是逐位分解,每位一个布尔约束,64 位就是 64 条约束加 64 条布尔性约束。一笔交易如果有几十个金额,约束数轻松上万。这就是为什么早期这类方案的证明生成要好几秒,后来用查表方案优化之后才降到可以接受的水平。
工程上还有个细节:金额承诺必须用加法同态的承诺方案,否则验证者没法在不解密的情况下检查守恒关系。这就是 3.2 节讲的代数承诺的用武之地。用哈希承诺做不了这件事,因为哈希没法从两个承诺推出和的承诺。
6.2 属性证明:只说结论,不出示原始材料
另一大类应用是"证明我满足某个属性,但不出示证明材料"。比如证明年龄超过某个阈值,但不出示出生日期;证明收入在某区间,但不出示工资单;证明某个设备在允许列表内,但不出示设备序列号。
这类场景的价值在于最小信息披露。传统做法是出示原件,验证方看到的信息远多于他需要判断的那一条。比如验证年龄时看到完整生日,验证收入时看到具体数额和雇主。零知识证明把披露范围压缩到"结论为真"这一比特。
实现这类方案,关键是把"属性判断"翻译成约束。以年龄为例,逻辑是"当前日期减去出生日期大于阈值",全部在域上做减法,然后对结果做范围检查。承诺的是出生日期,公开的是当前日期和阈值。验证者拿到的证明只说明"存在一个出生日期,使得减法结果落在指定区间内"。
这类方案我踩过的一个坑是时间基准。如果当前日期是公开输入,那么同一个证明在不同日期验证结果不同,这会导致证明的有效期问题和重放问题。解决办法是把当前日期也绑进哈希输入,让证明和验证时刻绑定,或者干脆用相对时间(比如"证明这个凭证在签发后一年内有效"),避免绝对时间的漂移。
6.3 可验证计算与批处理验证
第三大类应用是把计算搬走、把验证留下。典型模式是:一个不可信的算力提供方执行大量计算,然后生成一个证明,声明"我按你指定的程序算了这些输入,得到了这个输出"。验证方只需要验证一个证明,成本远低于重新算一遍。
这个模式的商业价值在于验证成本与计算量解耦。一笔验证成本固定的交易,可以代表成千上万次链下计算。这也是各种扩容方案的理论基础。
但要注意一个容易被忽略的边界:零知识证明保证的是"计算过程正确",不保证"输入是真实的"。如果算力提供方用的输入本身就是伪造的,证明再漂亮也没用。所以在设计这类系统时,输入的真实性要靠另外的机制保证,比如签名、哈希锚定、多方共识。我见过有方案把这两件事混为一谈,结果在输入层被攻破。
还有一个实践中的考量:证明生成是有延迟的。算力提供方算完还要花时间生成证明,这个延迟在某些场景下不可接受。所以这类方案通常配合批量提交,把延迟摊薄到一批交易上,单笔看着就不明显了。
6.4 选型决策清单
最后给一份我自己用的判断清单,按顺序问下来,基本能得出结论:
| 问题 | 如果答"是" | 如果答"否" |
|---|---|---|
| 是否必须在不泄露原始数据的前提下完成验证? | 继续往下问 | 大概率不需要零知识证明,用普通签名或哈希就够了 |
| 验证方是否不可信或不可控? | 需要非交互式方案 | 交互式方案可考虑,但要评估验证者随机性风险 |
| 验证是否需要公开、可重放? | 必须非交互式 + 哈希挑战 | 可以考虑交互式 |
| 是否有链上验证的成本约束? | 倾向小证明体积的方案 | 可选透明设置,省掉仪式成本 |
| 系统生命周期是否超过十年? | 认真评估后量子风险,倾向哈希型 | 配对型或内积型都可以 |
| 证明生成频率高不高? | 重点评估生成耗时和内存 | 可以接受较高开销,换取更小证明 |
| 能否承担可信设置的组织成本? | 可选范围 |