代表性面试主题

通用面试:如何验证 Merkle 树包含证明?

通用困难
Offer.cc 编辑团队发布 更新

题干

客户端拿到一个叶子哈希、leaf_index、tree_size、inclusion_path 和可信 root_hash。请设计验证算法,说明如何决定每层拼接方向、拒绝畸形证明并分析通信与计算复杂度。

题干与适用场景

这是密码学数据结构与协议实现题。Merkle 包含证明不是把整棵树发送给客户端,而是提供从目标叶子到根所需的兄弟节点列表。RFC 9162 将叶子和内部节点使用不同前缀进行哈希,并要求验证者结合 leaf_indextree_size 判断每层左右方向。题目假设客户端已经通过可信渠道获得 root_hash;验证算法本身不负责建立信任锚。

面试官考察点

  • 能否区分叶子哈希、内部节点哈希和证明路径的方向信息。
  • 能否用 leaf_indextree_size 做越界和路径长度检查。
  • 能否理解域分离,避免把叶子字节误当成内部节点输入。
  • 能否说明证明大小 O(log n)、验证时间 O(log n) 与可信根的边界。

回答前需要澄清的问题

先确认树的规范:是 RFC 9162 的可变大小 Merkle Tree,还是固定满二叉树;叶子是否已完成规范化;哈希算法与前缀常量是什么;路径是否按从叶到根排列。还要确认是否需要验证 append-only consistency proof、签名或只验证单个包含证明。没有这些约定,单独一串哈希无法唯一决定根。

30 秒回答框架

先检查 0 <= leaf_index < tree_size,并限制路径长度。把叶子规范化为 HASH(0x00 || leaf_bytes),然后维护 fn = leaf_indexsn = tree_size - 1 和当前哈希 r。每层根据 fn 的最低位或 fn == sn 决定兄弟节点在左还是右,使用内部节点前缀 0x01 拼接后哈希,再右移索引。最后要求 sn == 0r == root_hash

分步骤深入解答

1. 固定输入契约和域分离

证明验证器需要版本化的哈希算法、叶子编码、路径顺序和树大小语义。RFC 9162 的 Merkle Tree Hash 用 0x00 标识叶子、0x01 标识内部节点,防止同一字节串在两种角色之间产生歧义。实现不应直接对 leaf || sibling 做裸哈希,也不应接受调用方随意替换前缀。

2. 先做边界和资源检查

leaf_index >= tree_size 必须失败;空树没有合法叶子。路径长度应有协议上限,例如不超过 ceil(log2(tree_size)) + 1,并为每个哈希固定字节长度。验证器要拒绝整数溢出、负数编码、重复解析和超大路径,避免用异常证明消耗不受控资源。路径过短也不能直接当成功,最终状态必须能收敛到唯一根。

3. 逐层重建根哈希

RFC 9162 的可变树不能只看 leaf_index 的奇偶性;当目标节点位于当前子树边界时,fn == sn 会改变拼接方向。每层处理后同时右移 fnsn,把当前节点映射到上一层。伪代码如下:

text
verify(leaf, leafIndex, treeSize, path, expectedRoot):
  if treeSize <= 0 or leafIndex < 0 or leafIndex >= treeSize: return false
  r = HASH(0x00 || leaf)
  fn = leafIndex
  sn = treeSize - 1
  for sibling in path:
    if sn == 0: return false
    if (fn & 1) == 1 or fn == sn:
      r = HASH(0x01 || sibling || r)
    else:
      r = HASH(0x01 || r || sibling)
    fn = fn >> 1
    sn = sn >> 1
  return sn == 0 and r == expectedRoot

4. 验证路径与树大小一致

证明中的 tree_size 是方向计算的一部分,不能只把它当日志元数据。路径消费完时 sn 必须为零;若仍大于零,说明证明没有到达根。若中途 sn 已为零仍有额外节点,应拒绝。固定树实现可以用另一套规则,但必须把树形约定和生成器完全绑定,不能混用 RFC 9162 的路径。

5. 复杂度、通信和信任边界

平衡树的包含路径通常包含 O(log n) 个哈希,验证需要 O(log n) 次哈希和 O(1) 除路径外的状态,证明通信量为 O(log n * hashSize)。它只证明叶子对应给定 root_hash;如果根来自不可信响应,攻击者可以同时替换根和证明。生产协议还需签名、可信日志头或 TLS 认证来保护根及其树大小。

高质量示范回答

我会把验证器绑定到版本化树规范。先检查 tree_size > 00 <= leaf_index < tree_size、哈希长度和路径资源上限,再计算 r = HASH(0x00 || leaf)。维护 fn = leaf_indexsn = tree_size - 1,每层在 fn 为奇数或 fn == sn 时把兄弟放在左侧,否则放在右侧,并使用 HASH(0x01 || left || right) 更新。每轮同时右移两个索引;路径结束时只有 sn == 0 且结果等于可信根才成功。证明大小和验证成本都是 O(log n),但可信根、树大小和路径排序必须由协议保证。

常见错误

  • 只按索引奇偶性决定方向,忽略可变树的边界条件 fn == sn
  • 对叶子和内部节点使用同一个哈希前缀,丢失域分离。
  • 只比较重建根,不检查叶子越界、路径长度和 sn 是否归零。
  • 把不可信响应中的 root_hash 当作信任锚,误以为包含证明提供了认证。
  • 生成器用一种树形规则,验证器却按另一种满二叉树规则拼接。
  • 路径顺序、哈希字节序或叶子规范化不写入版本契约。

追问及应对

如何验证 append-only consistency proof?

包含证明只回答“某叶子在某个根中”。一致性证明还要同时重建旧树根和新树根,并证明旧树是新树的前缀;输入至少包括旧树大小、新树大小、证明路径和两棵树的可信根。两种证明的状态转移不同,不能复用一个只返回布尔值的函数。

为什么需要 tree_size,而不只传路径?

在可变大小树中,最后一个节点可能没有同层右兄弟,方向取决于当前子树边界。tree_size 让验证器知道哪些节点真实存在,并能拒绝把额外哈希伪装成根路径。

如何避免哈希算法降级?

把算法标识、哈希输出长度、叶子/内部前缀和规范化版本放在带版本的协议中。验证器只允许白名单算法,拒绝未知或弱算法;更换算法时产生新的根命名空间,不能把不同算法的摘要混在同一棵树里。

如何处理重复叶子?

包含证明证明的是位置和字节对应的叶子,不保证内容在整棵树中只出现一次。若业务要求唯一性,需要额外的唯一键索引或集合证明;不能从单个 Merkle 根推导“不存在第二个相同值”。

如何做增量生成而不存整棵树?

生成器可保存每一层最近的右侧子树摘要,形成前缀累加器;新叶到达时按二进制进位合并。要生成历史叶子的路径,仍需保存必要的节点或外部存储;只保留根无法反推出证明路径。

如果攻击者提交超长路径怎么办?

在解析前按树大小和哈希长度计算上限,拒绝超过上限的路径;对每个元素检查固定长度,避免整数乘法溢出。验证器应在任何哈希计算前完成资源预算,错误证明不能触发无限循环或大内存分配。

公开来源

同类题目