卷 III · 摊开CH 13深度 13/23

把一整张表压成一个指纹

上一章留下一个洞:验证者凭什么相信证明者报出来的那几个数是真的?他完全可以随口编。答案回到第 4 章——那几个数必须是从一个事先钉死的东西里「打开」出来的。一张 65536 格的表压成 32 字节的指纹;打开任意一格,附 512 字节的凭证。这三样东西凑齐,卷 III 就完成了:一台能用的简洁证明系统。

16 层512 字节承诺 + 抽查 + 打开

◻ 本章先赊三条
第一条。把整张表哈希一遍是没用的——那样打开一格就得给出整张表。真正需要的是「能局部打开」的承诺 第二条。局部打开的代价是对数级的:表大一千倍,凭证只长十个哈希。这是「简洁」这个词最后一块拼图。 第三条。另有一条完全不同的路,能把凭证压到一个群元素、且大小与表长完全无关——代价是一场必须有人诚实的仪式。

为什么不能只哈希一遍

第 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 的抗碰撞性一层层传上去的。

◆ 对数这件事,值得单独感受一下
表有多少格路径几个哈希打开一格多少字节
102410320
6553616512
10 亿30960
1 万亿401280

表涨了十亿倍,凭证只从 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 + FRIKZG
承诺是什么一个 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⁶ 倍量级。「简洁」不是省了力气,是把力气搬到了另一个人身上,而且搬过去的时候乘了三位数。