云端 Mac 上的 iOS Merkle 证明验收:分块完整性与树形边界

云端 Mac 上的 iOS Merkle 证明验收:分块完整性与树形边界

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 根可以和资源一起从任意位置下载吗?

可以一起传输,但必须通过受信渠道或已验证签名的清单认证根及其叶子数量。攻击者若能同时替换内容和根,证明仍可能通过。

奇数层最后一个节点是否需要复制自身?

本文协议直接将该节点提升到上一层,不消耗兄弟哈希。复制自身是另一种协议,生成端与验证端必须统一,不能混用。

把构建机交给机房

租用独享物理 Mac mini,把 Xcode 构建与 macOS CI 放在全年在线的整机上。按任务选择配置,按团队位置选择节点。

选择云端 Mac

把构建机交给机房

独享物理 Mac mini,全年 365 天在线。