iOS 离线资源按块下载后,单块校验通过,仍可能拼出错误版本:客户端接受了另一个清单的块,或者生成端与验证端对奇数节点采用不同规则。把验证器放到云端 Mac 的固定 Swift 环境中,先用小树验收协议,再接入真实下载链路,比下载完成后只比较一次总哈希更容易定位问题。
固定树协议与可信输入
本文使用 SHA-256。叶子计算 H(0x00 || 原始块字节),内部节点计算 H(0x01 || 左哈希 || 右哈希);前缀用于区分两种输入。哈希按原始的 32 字节拼接,不拼十六进制字符串。
每层若剩一个节点,直接提升,不复制自身。证明从叶子向根排列,仅包含实际存在的兄弟节点;左右方向由叶子索引和当前层宽度推导。
清单必须绑定资源版本、分块长度、总字节数、叶子数量和根。最后一块可以短于标准块长,空资源单独定义,不在验证器里猜测。
Merkle 证明回答“这个块是否属于给定根”,不回答“这个根是谁发布的”。根和叶子数量必须来自受信渠道或已验证签名的清单。
实现严格的证明验证器
在云端 Mac 保存以下内容为 MerkleCheck.swift。先运行 xcrun swift --version 记录工具链,再运行 xcrun swift MerkleCheck.swift。代码只依赖系统框架,不需要下载第三方包。
import Foundation
import CryptoKit
func leaf(_ bytes: Data) -> Data {
Data(SHA256.hash(data: Data([0]) + bytes))
}
func parent(_ left: Data, _ right: Data) -> Data {
Data(SHA256.hash(data: Data([1]) + left + right))
}
func verify(
chunk: Data,
index: Int,
count: Int,
siblings: [Data],
root: Data
) -> Bool {
guard count > 0, index >= 0, index < count,
root.count == 32,
siblings.allSatisfy({ $0.count == 32 })
else { return false }
var value = leaf(chunk)
var position = index
var width = count
var consumed = 0
while width > 1 {
let hasSibling = position % 2 == 1 || position < width - 1
if hasSibling {
guard consumed < siblings.count else { return false }
let sibling = siblings[consumed]
value = position % 2 == 0
? parent(value, sibling)
: parent(sibling, value)
consumed += 1
}
position /= 2
width = width / 2 + width % 2
}
return consumed == siblings.count && value == root
}
func require(_ condition: Bool, _ name: String) {
guard condition else {
fatalError("Check failed: \(name)")
}
}
let a = Data("A".utf8)
let b = Data("B".utf8)
let c = Data("C".utf8)
let ab = parent(leaf(a), leaf(b))
let root = parent(ab, leaf(c))
require(verify(chunk: c, index: 2, count: 3,
siblings: [ab], root: root), "odd promotion")
require(verify(chunk: a, index: 0, count: 3,
siblings: [leaf(b), leaf(c)], root: root), "left branch")
require(!verify(chunk: Data("X".utf8), index: 2, count: 3,
siblings: [ab], root: root), "tampered chunk")
require(!verify(chunk: c, index: 2, count: 3,
siblings: [ab, ab], root: root), "excess proof")
require(!verify(chunk: c, index: 3, count: 3,
siblings: [ab], root: root), "invalid index")
成功时脚本静默退出,失败时返回非零状态。归档执行状态即可,不要用搜索某句成功文本代替退出码。
用小树覆盖形状边界
三叶树中,第三个叶子在第一层直接提升,因此只需一个兄弟哈希。若生成端复制末节点,得到的根将不同;这不是网络错误,也不该靠重试解决。
把脚本迁入测试目标后,至少补齐下列检查:
- 单叶树:空证明通过,附加任意节点失败。
- 双叶树:左右叶子分别通过,交换位置失败。
- 三叶及五叶树:末叶提升正确,少一个或多一个证明节点失败。
- 非法输入:负索引、零叶子、短哈希和越界索引全部拒绝。
示例用同一套哈希函数构造根,只能验证控制流。还应由独立实现生成固定向量,提交块字节、根和证明的十六进制值,防止生成器与验证器共享同一个错误。不同位置若内容完全相同,仅凭块字节无法区分身份;需要位置绑定时,应在协议中加入索引及明确的整数编码。
把验证放在写入之前
下载层先核对清单中的块长度与索引,再验证证明。通过后写入该资源版本的暂存目录;所有块齐备后,检查总长度并发布完成标记。不要让界面把“文件已存在”当成“资源可用”。
证明属于不可信输入,解析前限制响应体大小、节点数量及哈希长度。树的有效证明最多随层数增长,不应接受无限数组。还要限制原始块大小,避免验证前就因整块载入而耗尽内存。
重试必须沿用同一清单快照。若下载期间发现新版本,应结束旧任务或继续旧版本,不能拿新根验证旧块。验收日志记录资源版本、块索引、失败类别即可,不输出块内容或访问凭据。
建立可解释的回归结果
在固定工具链上分别执行正确向量与恶意向量。正确输入被拒绝,优先检查奇数层规则、证明顺序和字节编码;错误输入被接受,先检查是否漏验索引、剩余证明或可信根来源。
最后做一次端到端故障注入:替换暂存块的一个字节,确认该块被拒绝且完成标记没有生成;再恢复正确块,确认任务能继续。验收标准不是“哈希代码跑完”,而是任何未通过证明的数据都不能进入可读取的资源集合。
常见问题
Merkle 根可以和资源一起从任意位置下载吗?
可以一起传输,但必须通过受信渠道或已验证签名的清单认证根及其叶子数量。攻击者若能同时替换内容和根,证明仍可能通过。
奇数层最后一个节点是否需要复制自身?
本文协议直接将该节点提升到上一层,不消耗兄弟哈希。复制自身是另一种协议,生成端与验证端必须统一,不能混用。