你要向别人证明一件事,但不能让他知道你是怎么知道的

听起来自相矛盾——证明不就是把理由摆出来吗?

⚠️ 这一讲第一次读会持续有一种"这不可能"的感觉,这很正常。下面第一节那个色盲球的例子,是为了先让你相信它确实可能,再去问它是怎么做到的。先接受结论,再理解机制,在这一讲里是更好的顺序。

第 28 讲用了零知识证明这个工具,但没说它是什么。这一讲补上。

本讲的范围:讲到"它证明的到底是什么陈述、简洁性从哪来"为止,不展开 R1CS/QAP/多项式承诺的代数细节——再往下就是一门独立的密码学课了。

一、一个直觉例子

你的朋友是色盲。你手上有两个球,除了颜色完全相同——一红一绿。 如何让他相信"这两个球颜色不同",而不告诉他哪个是红的?

① 他把两个球拿到背后,可能交换,也可能不交换
② 他拿出来问你:"我换了吗?"
③ 你回答"换了"或"没换"

⭐ 如果两球真的不同色,你每次都能答对。
如果它们其实一样,你只能瞎猜,答对概率 1/2。

⟹ 重复 30 次,你全对的概率是 2⁻³⁰ ≈ 十亿分之一。
⟹ 他【确信】两球不同色,但他【仍然不知道】哪个是红的。

打个比方

先把这个例子里"零知识"发生在哪一步说清楚,因为它很容易被读过去。

你的朋友最后确信了"两球不同色"——这是他获得的知识。而"哪个是红的",他从头到尾一无所知——这是他没获得的知识

关键在于:你回答的每一句"换了/没换",单独看都不携带颜色信息。他能从这一串回答里推出的,只有"你有区分它们的能力"这一件事。

⭐ 这个小游戏已经包含了零知识证明的全部三个要素:

① 完备性:陈述为真时,诚实的证明者总能通过
② 可靠性:陈述为假时,作弊者【以极高概率】被抓住
   ⭐ 而它的来源是【随机挑战】——作弊者无法预测下一次问什么
③ 零知识:验证者除了"陈述为真"之外,什么也没学到

二、把三个性质写精确

设陈述为 "∃w : C(x, w) = 1"
   x 是公开输入,w 是只有证明者知道的秘密(称为 witness / 见证)

① 完备性
   若确实存在这样的 w,⭐ 诚实证明者能让验证者接受(概率 1)

② 可靠性(soundness)
   若不存在这样的 w,任何作弊证明者让验证者接受的概率可忽略

   更强的版本叫【知识可靠性】:
      不仅"存在 w",而且证明者【真的知道】它。
      而"知道"这个词怎么形式化?见第四节的知识提取器。

③ 零知识
   验证者从交互中学到的东西,
      和他【自己坐在家里凭空编造】出来的一样多。
   这个"自己编造"的形式化就是【模拟器】,见第五节。

注意 ②③ 的定义方式很特别:它们不是直接描述性质,而是通过"存在某个算法"来定义。这是密码学最典型的思维方式,下面两节各展开一个。

三、Schnorr Sigma 协议

用一个真实可用的协议把上面三条落地。

陈述:我知道一个 x,使得 P = x·GPG 公开)。

即:我知道公钥 P 对应的私钥。

       证明者 P(知道 x)              验证者 V
              │                            │
    选随机 r  │  ── R = r·G ──────────────▶│    ① 承诺
              │                            │
              │ ◀────── 随机 e ────────────│    ② 挑战
              │                            │
  s = r + e·x │  ── s ────────────────────▶│    ③ 响应
              │                            │
              │                    验证 s·G == R + e·P

这个"承诺 → 挑战 → 响应"的三步结构就叫 Sigma 协议,它是绝大多数实用零知识协议的骨架。

完备性

s·G = (r + e·x)·G = r·G + e·(x·G) = R + e·P    ✅

四、可靠性:知识提取器定义了"知道"

怎么形式化"证明者真的知道 x"? 密码学的答案很巧妙:

如果我们能【从证明者身上把 x 抠出来】,那他就是知道 x。

具体做法:

假设一个证明者能对【同一个 R】回答【两个不同的挑战】 e₁ ≠ e₂:

    s₁ = r + e₁·x
    s₂ = r + e₂·x
    ────────────────
    s₁ − s₂ = (e₁ − e₂)·x

⟹ ⭐ x = (s₁ − s₂) / (e₁ − e₂)

这就是"知识提取器":一个算法,把证明者当作黑盒反复调用(每次给不同的挑战),就能算出 x

⟹ 逻辑是:能通过验证 ⟹ 存在提取器能抠出 x ⟹ 他必然知道 x
⟹ ⭐ 反过来说:不知道 x 的人无法通过(除非猜中挑战,概率可忽略)

⚠️ 注意这段推导和第 7 讲的 k 重用攻击在形式上完全相同——那里是攻击,这里是安全性证明。同一个代数事实,用途相反。

五、零知识:模拟器定义了"什么也没学到"

⭐ 这一节把开头那句"他没获得哪些知识"写成了定义:如果一个不知道秘密的人,能造出和真实对话分布一样的记录,那么这段对话里就不含任何关于秘密的信息。 这就是"零知识"的准确含义。

怎么形式化"验证者什么也没学到"? 答案同样巧妙:

如果验证者能【自己伪造出一份和真实交互一模一样的记录】,那这份记录里就没有任何信息。

来看怎么伪造。诚实的顺序是"先 R,再 e,最后 s"。模拟器不遵守这个顺序:

① ⭐ 先随便挑一个 e 和一个 s
② 再倒推出 R := s·G − e·P

验证一下:s·G == R + e·P ?
          代入 R = s·G − e·P
          s·G == s·G − e·P + e·P = s·G    ✅ 恒成立

于是模拟器造出了一份 (R, e, s),它通过验证,且分布与真实交互完全相同——但整个过程中它根本不知道 x

⟹ ⭐ 真实交互的记录里,不可能包含任何关于 x 的信息。
   因为一个不知道 x 的人,能造出统计上无法区分的记录。

⚠️ 注意这里的关键前提:模拟器"作弊"的方式是打乱了顺序(先定 e 再定 R)。 所以:

⭐ 零知识性成立的条件是:挑战 e 必须在 R 【之后】、并且【真随机】地产生。
如果验证者能根据 R 来【挑选】对自己有利的 e,上面的论证就不成立。

这就是为什么"随机挑战"在零知识里是核心而非细节——它同时支撑了可靠性和零知识性两条。

六、Fiat-Shamir:把交互变成签名

交互式协议在链上不可用——你没法让合约和用户来回三次。

Fiat-Shamir 变换的想法极其简单:

让证明者自己用哈希函数生成挑战。

e := H( R ‖ P ‖ 消息 )
⭐ 由于哈希不可预测(第 4 讲的雪崩效应),
   证明者【无法先选 e 再倒推 R】——
   他必须先定下 R,而 e 就被哈希确定了。
⟹ 交互被消除,证明变成一条可以发布的消息。

于是我们得到了一个重要的认识:

把 Schnorr Sigma 协议做 Fiat-Shamir 变换,
⭐ 得到的正是第 7 讲的 Schnorr 【签名】。

⟹ 数字签名,本质上就是
   "我知道这个公钥对应的私钥"这一陈述的
   【非交互零知识证明】。

一个真实的坑:弱 Fiat-Shamir

⚠️ 哈希里必须包含【全部公开输入】。

如果只写 e = H(R) 而漏掉了 P 或消息,
攻击者就可以先固定 R 和 e,再去【挑选】一个能让等式成立的 P,
⟹ 伪造出"我知道 P 的私钥"的证明。

这个漏洞在真实的 ZK 项目中被发现过多次(业内称为 “Frozen Heart” 类漏洞)。它和第 7 讲第七节是同一条教训:没被纳入哈希的东西,攻击者可以随意选择。

七、从 Sigma 到通用 ZK

Sigma 协议只能证明特定代数结构的陈述(比如"我知道离散对数")。通用零知识要证明的是任意计算。

第一步:把计算变成约束

⭐ 任何确定性计算,都可以被表达成一组【算术约束】。

例:"我知道一个 w,使得 SHA256(w) = 0xabc..."
   ⟹ 把 SHA256 的每一步位运算展开成加法和乘法约束
   ⟹ 得到几万条形如 "a × b = c" 的方程
   ⟹ w 就是满足全部方程的那组赋值(witness)

于是所有 ZK 系统要证明的陈述,统一成一句话:

“我知道一组赋值 w,使得公开的约束系统 C(x, w) 全部被满足。”

第 28 讲的关键警告在这里获得了准确含义

⭐ 证明保证的是"存在满足【这组约束】的赋值"。
如果这组约束没有正确地刻画 EVM 的语义(少写了一条),
   那么"满足约束"就不等于"执行正确"。
⟹ 电路 bug 无法被证明系统本身发现。

第二步:简洁性从哪来

有几万条约束,逐条检查就没有意义了。简洁性的核心技巧是:

① ⭐ 把"所有约束都成立"编码成【一个多项式恒等式】
      P(X) ≡ 0  在所有约束点上成立

② 验证者【随机】挑一个点 z,只检查 P(z) = 0

为什么检查一个点就够?——Schwartz–Zippel 引理:

⭐ 两个不同的 d 次多项式,在一个随机点上取值相等的概率 ≤ d / |F|

代入实际参数:d ≈ 10⁶(约束数量),|F| ≈ 2²⁵⁴(域大小)

⟹ 概率 ≈ 10⁶ / 2²⁵⁴ ≈ 10⁻⁷0

⭐⭐ 这就是简洁性的全部来源:

“检查一百万条约束"被换成了"检查一个随机点”, 而出错的概率是 10⁻⁷⁰——比宇宙中的原子数的倒数还小。

于是验证成本与计算量脱钩了——这正是第 3 讲说 ZK 是唯一可能真正打破「不可能三角」的方向的原因。

剩下的技术细节(如何让证明者无法在知道 z 之后再构造多项式——用多项式承诺;如何让整个过程非交互——Fiat-Shamir)就是各个证明系统的差异所在,这里不展开。

八、三个常见误解

❌ "零知识 = 匿名"
   ⭐ ZK 是一个证明工具,不是隐私方案。
      ZK Rollup 里的交易【完全公开】(第 28 讲)。
      隐私需要专门设计(第 33 讲)。

❌ "ZK 证明保证了正确性"
   它保证"电路被正确执行",不保证"电路写对了"(第 28 讲)。

❌ "ZK 都需要可信设置"
   STARK 不需要(第 28 讲)。且现代 SNARK 的通用设置
      可以在多个应用间复用,而不是每个电路一次。

九、Go:Sigma 协议与 Fiat-Shamir

package zk

import (
	"crypto/elliptic"
	"crypto/rand"
	"math/big"

	"golang.org/x/crypto/sha3"
)

// Transcript 是一次 Schnorr 证明的完整记录。
type Transcript struct {
	R *Point   // ① 承诺
	E *big.Int // ② 挑战
	S *big.Int // ③ 响应
}

type Point struct{ X, Y *big.Int }

// Prove 生成一个"我知道 x 使 P = x·G"的非交互证明(Fiat-Shamir 后)。
// ⭐ 这个函数实质上就是 Schnorr 签名。
func Prove(curve elliptic.Curve, x *big.Int, P *Point, msg []byte) (*Transcript, error) {
	n := curve.Params().N

	// ① 承诺:R = r·G
	r, err := rand.Int(rand.Reader, n)
	if err != nil {
		return nil, err
	}
	rx, ry := curve.ScalarBaseMult(r.Bytes())
	R := &Point{rx, ry}

	// ② 挑战:由证明者自己用哈希生成(Fiat-Shamir)
	// 必须包含【全部】公开输入。漏掉 P 或 msg 会导致可伪造(弱 Fiat-Shamir)。
	e := challenge(R, P, msg, n)

	// ③ 响应:s = r + e·x mod n
	s := new(big.Int).Mul(e, x)
	s.Add(s, r)
	s.Mod(s, n)

	return &Transcript{R: R, E: e, S: s}, nil
}

// Verify 检查 s·G == R + e·P。
func Verify(curve elliptic.Curve, P *Point, msg []byte, t *Transcript) bool {
	n := curve.Params().N

	// 必须自己【重算】挑战,绝不能信任证明里带来的 E
	e := challenge(t.R, P, msg, n)
	if e.Cmp(t.E) != 0 {
		return false
	}

	sx, sy := curve.ScalarBaseMult(t.S.Bytes())     // s·G
	ex, ey := curve.ScalarMult(P.X, P.Y, e.Bytes()) // e·P
	rx, ry := curve.Add(t.R.X, t.R.Y, ex, ey)       // R + e·P

	return sx.Cmp(rx) == 0 && sy.Cmp(ry) == 0
}

// challenge 计算 e = H(R ‖ P ‖ msg) mod n。
// 把全部公开输入喂进哈希,是 Fiat-Shamir 安全性的前提。
func challenge(R, P *Point, msg []byte, n *big.Int) *big.Int {
	h := sha3.NewLegacyKeccak256()
	h.Write(R.X.Bytes())
	h.Write(R.Y.Bytes())
	h.Write(P.X.Bytes()) // 漏掉这两行就是 Frozen Heart 类漏洞
	h.Write(P.Y.Bytes())
	h.Write(msg)
	return new(big.Int).Mod(new(big.Int).SetBytes(h.Sum(nil)), n)
}

// ─────────── 第四节:知识提取器 ───────────

// Extract 演示"知识"的形式化定义:
// 若证明者能对【同一个 R】回答两个不同的挑战,就能把 x 抠出来。
// 注意这与第 7 讲的 k 重用攻击是同一个代数事实——
// 在那里它是攻击,在这里它是安全性证明。
func Extract(n, e1, s1, e2, s2 *big.Int) *big.Int {
	// x = (s₁ − s₂) / (e₁ − e₂) mod n
	ds := new(big.Int).Sub(s1, s2)
	de := new(big.Int).Sub(e1, e2)
	x := new(big.Int).Mul(ds, new(big.Int).ModInverse(de, n))
	return x.Mod(x, n)
}

// ─────────── 第五节:模拟器 ───────────

// Simulate 演示"零知识"的形式化定义:
// 不知道 x,也能造出一份通过验证、且分布相同的记录。
// 关键在于它【打乱了顺序】:先选 e 和 s,再倒推 R。
// 这也是为什么挑战必须在承诺之后、且真随机地产生。
func Simulate(curve elliptic.Curve, P *Point) (*Transcript, error) {
	n := curve.Params().N

	e, err := rand.Int(rand.Reader, n) // 先随便选挑战
	if err != nil {
		return nil, err
	}
	s, err := rand.Int(rand.Reader, n) // 再随便选响应
	if err != nil {
		return nil, err
	}

	// 倒推 R := s·G − e·P
	sx, sy := curve.ScalarBaseMult(s.Bytes())
	ex, ey := curve.ScalarMult(P.X, P.Y, e.Bytes())
	negEy := new(big.Int).Sub(curve.Params().P, ey) // −(e·P)
	rx, ry := curve.Add(sx, sy, ex, negEy)

	return &Transcript{R: &Point{rx, ry}, E: e, S: s}, nil
}

// SchwartzZippelBound 返回"两个不同的 d 次多项式在随机点相等"的概率上界。
// 这是简洁性的全部来源:检查一个随机点 ≈ 检查整个多项式。
// d ≈ 10⁶、域大小 ≈ 2²⁵⁴ 时,概率约 10⁻⁷⁰。
func SchwartzZippelBound(degree uint64, fieldBits uint) *big.Float {
	d := new(big.Float).SetUint64(degree)
	f := new(big.Float).SetInt(new(big.Int).Lsh(big.NewInt(1), fieldBits))
	return new(big.Float).Quo(d, f)
}

十、本讲小结

  • 色盲朋友的例子已包含全部三要素:完备性、可靠性(来自随机挑战——作弊者无法预测下一次问什么)、零知识。
  • Sigma 协议的"承诺 → 挑战 → 响应"是绝大多数实用零知识协议的骨架。
  • ⭐⭐ 知识提取器定义了"知道":若证明者能对同一个承诺回答两个不同挑战,就能解出 x = (s₁−s₂)/(e₁−e₂)这与第 7 讲的 k 重用攻击是同一个代数事实——那里是攻击,这里是安全性证明。
  • ⭐⭐ 模拟器定义了"什么也没学到":不知道 x 也能造出通过验证、分布相同的记录(先选 e、s,再倒推 R)⟹ 真实记录里不可能含有 x 的信息。
  • 随机挑战同时支撑了可靠性和零知识两条——零知识的论证依赖"e 在 R 之后真随机产生",否则不成立。
  • Fiat-Shamir 用 e = H(R‖P‖msg) 消除交互。于是:数字签名本质上就是"我知道这个公钥的私钥"的非交互零知识证明。
  • 弱 Fiat-Shamir 是真实漏洞:哈希漏掉公开输入,攻击者就能先固定 R、e 再挑选 P 来伪造。与第 7 讲同一条教训:没被纳入哈希的东西可以被随意选择。
  • 所有通用 ZK 要证明的陈述统一成一句话:“我知道一组赋值 w,使公开的约束系统 C(x,w) 全部满足”。由此可见电路 bug 为何无法被证明系统发现——它保证的是"满足约束",而不是"约束刻画正确"。
  • 简洁性的全部来源是 Schwartz–Zippel:把"所有约束成立"编码成一个多项式恒等式,随机检查一个点即可。d ≈ 10⁶、域 ≈ 2²⁵⁴ 时出错概率约 10⁻⁷⁰验证成本由此与计算量脱钩——这正是第 3 讲说它是唯一可能打破"不可能三角"的方向的原因。
  • 三个误解:ZK ≠ 匿名(ZK Rollup 的交易完全公开)、ZK 不保证电路写对、STARK 不需要可信设置。

思考题

  1. 色盲朋友的游戏里,如果只玩 3 轮,作弊者蒙混过关的概率是多少?要压到百万分之一需要几轮?
  2. 完整推导 Schnorr 协议的完备性。
  3. 用知识提取器证明可靠性:为什么"能对两个不同挑战作答"就意味着"知道 x"?不知道 x 的人成功概率是多少?
  4. 模拟器为什么必须"打乱顺序"?如果验证者能看到 R 之后再挑选 e,零知识性还成立吗?请给出一个具体的攻击。
  5. 说明为什么"数字签名是非交互零知识证明"。签名里的消息 m 在这个视角下扮演什么角色?
  6. 构造一个弱 Fiat-Shamir 攻击:假设 e = H(R),写出攻击者伪造证明的完整步骤。
  7. 把"我知道一个数 w 使得 w² = 25“表达成算术约束。再把"我知道 SHA256 的一个原像"呢?难度差别在哪?
  8. 用 Schwartz–Zippel 计算:约束数 10⁸、域大小 2⁶⁴ 时的出错概率是多少?这个域够安全吗?
  9. 为什么"检查一个随机点"能代替"检查一百万条约束”?证明者能不能在知道那个点之后再构造多项式来作弊?(提示:想想还需要什么机制)
  10. “我们用了零知识证明所以用户隐私得到保护”——请指出这句话在什么情况下成立、什么情况下完全不成立。