把一整张表压成一个指纹
上一章留下一个洞:验证者凭什么相信证明者报出来的那几个数是真的?他完全可以随口编。答案回到第 4 章——那几个数必须是从一个事先钉死的东西里「打开」出来的。一张 65536 格的表压成 32 字节的指纹;打开任意一格,附 512 字节的凭证。这三样东西凑齐,卷 III 就完成了:一台能用的简洁证明系统。
为什么不能只哈希一遍
第 4 章那个哈希承诺,对付「一个数」很好用。对付「一张 65536 格的表」就不行了:
承诺 = SHA-256(整张表) ← 藏得住 ✓ 改不了 ✓
但是「打开第 700 格」怎么做?
唯一的办法是把整张表都交出来,让验证者自己算一遍哈希。
——那验证者就读完整张表了,回到第 1 章的路 B。
我们需要的是一种承诺,它允许只打开一格,而且能证明「这一格确实来自那个被钉死的整体」。
Merkle 树
做法是 1979 年 Ralph Merkle 提出的,非常朴素:
把每一格哈希一次,得到 65536 个叶子。
两两配对,把每一对拼起来再哈希,得到 32768 个节点。
再两两配对……重复 16 次,最后剩下一个 32 字节的「根」。
打开第 700 格:
交出这一格的值,外加从它到根这条路径上的 16 个「兄弟」哈希。
验证者拿着这 17 样东西,自己一层层往上算,最后应该正好得到那个根。
本机跑(65536 片叶子):
叶子数 65536 层数(一条路径要几个哈希) 16 根 4f238f7bc1484bb1dc0e0ea5…(32 字节) 建整棵树用了 120 ms,共 131071 次 SHA-256 打开一格要多少字节 512 + 值 抽 3 格的证明大小 1.5 KB 抽 34 格的证明大小 17.1 KB 抽 80 格的证明大小 40.2 KB
为什么改不了?因为要让某一格的值变化而根不变,你得找到一处哈希碰撞。整棵树的「改不了」,是从 SHA-256 的抗碰撞性一层层传上去的。
| 表有多少格 | 路径几个哈希 | 打开一格多少字节 |
|---|---|---|
| 1024 | 10 | 320 |
| 65536 | 16 | 512 |
| 10 亿 | 30 | 960 |
| 1 万亿 | 40 | 1280 |
表涨了十亿倍,凭证只从 320 字节涨到 1280 字节。这就是第 1 章那句「验证的工作量和题目大小几乎脱钩」的具体形态——不是完全脱钩,是对数级地脱钩,而对数在实用范围内约等于常数。
三样东西凑齐了
现在把第 4 章那张四步表填满,卷 III 就结束了:
| 步骤 | 用什么做 | 哪一章 | 代价 |
|---|---|---|---|
| 1. 把编码表承诺掉 | Merkle 树,交出 32 字节的根 | 本章 | 131071 次哈希 |
| 2. 抛出随机挑战:戳哪 34 格 | Fiat–Shamir:c = H(根 ‖ 公开输入 ‖ …) | 第 8 章 | 1 次哈希 |
| 3. 打开那 34 格 | 每格附一条 16 个哈希的路径 | 本章 | 17.1 KB |
| 4. 检查这些格子是否自洽 | 约束等式 + 低度扩展的码距 | 第 11、12 章 | 544 次哈希 |
这就是一台 STARK 的骨架。真实系统还有几层技术细节(多条曲线要一起承诺、FRI 的折叠、商多项式的处理),但形状就是这四步,一样不多。
而「零知识」这条性质呢?它几乎是白送的。验证者只看到 34 格的值,而整张表有 65536 格——他自然学不到全貌。要做到严格意义上的零知识,只需再加一个小动作:在编码之前,往表里掺几个随机数(这叫 blinding),让被打开的那几格的分布和秘密无关。代价是几条额外的约束。
上面这套东西的正式名字是多项式承诺方案(polynomial commitment scheme)。定义只有三条:
- 承诺:给一条曲线,输出一个短的承诺值。
- 打开:给一个点 r,输出 f(r) 以及一份「这个值确实是那条被承诺的曲线在 r 处的取值」的凭证。
- 绑定:承诺完之后,你没法对同一个点打开出两个不同的值。
整个现代证明系统领域,可以粗暴地概括成一句话:先把问题化归成「多项式恒等式」(第 12 章),再插一个多项式承诺方案进去(本章)。换一个承诺方案,就得到一个不同名字的系统。这是第 16 章那张选型表的组织原则。
另一条路:把凭证压到一个群元素
Merkle 路径是 512 字节,34 条就是 17 KB。有没有更短的?有,而且短得离谱。
KZG 承诺(2010)用的是第 5 章那种「可加」的椭圆曲线结构,而不是哈希:
| Merkle + FRI | KZG | |
|---|---|---|
| 承诺是什么 | 一个 32 字节哈希 | 一个群元素(48 字节) |
| 打开一个点的凭证 | 16 个哈希 = 512 字节,随表长按对数增长 | 一个群元素 = 48 字节,与表长完全无关 |
| 验证要做什么 | 算 16 次哈希 | 算一次「配对」(pairing) |
| 需要可信设置吗 | 不需要 | 需要——一场仪式,见第 15 章 |
| 抗量子吗 | 目前认为是(只靠哈希) | 否(椭圆曲线,Shor 算法能破) |
| 用在哪 | STARK、Plonky、以太坊的一部分 | PlonK、以太坊的 blob(EIP-4844) |
这就是第 5 章那两条路线在承诺这一层的正面对撞:哈希路线便宜、假设干净、但凭证是对数级;代数路线凭证是常数级、极短,但要多一个假设,还要一场仪式。
第 15 章讲那场仪式,第 16 章把这两条路线的全部取舍摊成一张表。
一棵能用的 Merkle 树只要二十行,而且你会跑出和这本书一模一样的根:
import hashlib, time
sha = lambda b: hashlib.sha256(b).digest()
def build(vals):
layer = [sha(str(v).encode()) for v in vals]
layers = [layer]
while len(layer) > 1:
layer = [sha(layer[i] + layer[i+1]) for i in range(0, len(layer), 2)]
layers.append(layer)
return layers
def open_at(layers, i): # 打开第 i 格:收集一路的兄弟
path = []
for lv in range(len(layers) - 1):
path.append(layers[lv][i ^ 1])
i >>= 1
return path
def verify(root, i, v, path): # 验证:自己一层层算上去
node = sha(str(v).encode())
for sib in path:
node = sha(sib + node) if i & 1 else sha(node + sib)
i >>= 1
return node == root
N = 65536
vals = list(range(N))
t = time.perf_counter()
layers = build(vals)
root = layers[-1][0]
print('叶子 %d,层数 %d,根 %s…' % (N, len(layers)-1, root.hex()[:24]))
print('建树 %.0f ms,共 %d 次 SHA-256' % ((time.perf_counter()-t)*1000, 2*N-1))
path = open_at(layers, 700)
print('路径 %d 个哈希 = %d 字节' % (len(path), 32*len(path)))
print('验证通过:', verify(root, 700, vals[700], path))
print('把值改掉:', verify(root, 700, 12345, path))
print('34 次查询 %.1f KB' % (34*(len(path)*32+2)/1024))
叶子 65536,层数 16,根 4f238f7bc1484bb1dc0e0ea5… 建树 55 ms,共 131071 次 SHA-256 路径 16 个哈希 = 512 字节 验证通过: True 把值改掉: False 34 次查询 17.1 KB
那个根 4f238f7b… 和书里印的完全一样——这本书用 JavaScript 生成,你用 Python 跑,结果逐字节相同。这就是哈希函数的意义。
值得亲手试的两件事:(1) 把 N 改成 1024 或 1048576,看层数怎么变(10 和 20),路径字节数怎么变(320 和 640)。(2) 把 path 里任何一个哈希改一个字节,验证立刻变 False。
python3 mk.py # 本机 55 毫秒
在线跑:python.org/shell(N 调到 4096 更快)。注意生产实现要处理叶子数不是 2 的幂的情况,还要防「第二原像攻击」(叶子和内部节点用不同的前缀哈希)。
- Git。一个 commit hash 是一棵 Merkle 树的根(第 4 章提过)。
git log --oneline里那串短哈希,压的是整个仓库在那一刻的全部内容。而git fetch只拉变化的那些对象——这正是「局部打开」。 - 比特币的 SPV 轻钱包。手机上的钱包不下载完整区块(几 MB),它只要区块头(80 字节)加一条 Merkle 路径,就能确认「我这笔交易在这个区块里」。2008 年中本聪的白皮书里就写了这一段,比这本书讲的其他东西都早。
- Certificate Transparency。全世界所有 HTTPS 证书都被登记进一棵公开的 Merkle 树。它保证了两件事:CA 签发过的证书藏不住(append-only),任何人都能只用一条路径证明某张证书在册。谷歌浏览器强制要求证书带这个证明。
- IPFS、Docker 镜像层、ZFS、Cassandra 的反熵修复、AWS S3 的分片校验。Merkle 树是过去二十年分布式系统里被复用最多的一个结构,多到大部分工程师用过而不知道它的名字。
- 以太坊的状态树。整条链上所有账户的余额和存储,压成一个 32 字节的状态根写进区块头。第 18 章那件事之所以可能,前提就是「整个世界的状态可以被一个指纹代表」。
「Merkle 树就是为了校验数据完整性嘛,跟直接哈希整个文件比也没啥本质区别,就是分块算了一下。」
完整性校验是 Merkle 树最不重要的用途——那件事一个 SHA-256 就够了。它真正的价值是「局部打开」:证明「这一小块属于那个整体」,而不必出示整体。
这个区别在成本上是天壤之别:一个整文件哈希,要验任何一部分都得下载全部(n);Merkle 树验一部分只要 log n。比特币轻钱包省下的是几百 GB,以太坊轻客户端省下的是整条链。
更重要的是,「局部打开」正是让抽查变得可能的那个零件。第 3 章说抽查需要「他不能事后改答案」,第 4 章给了承诺,而这一章才让承诺和抽查真正配合起来:钉死整体,只开被戳中的那几格。把这三章连起来,你就有了这本书前半部分的完整答案。判据是:看到一个「哈希一下做校验」的设计,问一句「以后需不需要只验其中一部分」——如果需要,现在就该用树。
这一章的一句话
承诺必须支持「只打开一格」,抽查才有意义;Merkle 树用对数级的代价做到了这件事,而它和第 11 章的编码、第 12 章的随机点凑在一起,就是一台完整的简洁证明系统——「零知识」在这个结构里几乎是白送的。
卷 III 到此结束。你已经知道这台机器是怎么造出来的了。
下一卷问一个到现在为止被刻意回避的问题:这一切要花多少钱?验证者确实只做了 544 次哈希——那证明者呢?本机实测的答案不好看:证明一段 8192 步的计算,比直接跑它慢 164 倍。而这还是一台只做了一半工作的玩具;工业实现的开销在 10⁴ 到 10⁶ 倍量级。「简洁」不是省了力气,是把力气搬到了另一个人身上,而且搬过去的时候乘了三位数。