When iOS downloads an offline resource in chunks, every chunk can pass verification and still produce the wrong version when assembled: the client may have accepted a chunk from a different manifest, or the generator and verifier may handle odd numbers of nodes differently. Running the verifier in a pinned Swift environment on a cloud Mac lets you validate the protocol with small trees before connecting it to the real download pipeline. This makes problems easier to isolate than comparing a single whole-resource hash after the download finishes.
Define the tree protocol and trusted inputs
This article uses SHA-256. Leaves are computed as H(0x00 || raw chunk bytes), and internal nodes as H(0x01 || left hash || right hash); the prefixes distinguish the two input types. Hashes are concatenated as raw 32-byte values, not hexadecimal strings.
If a level has one unpaired node, promote it directly without duplicating it. Proofs run from leaf to root and contain only siblings that actually exist. Left and right placement is derived from the leaf index and the width of the current level.
The manifest must bind together the resource version, chunk length, total byte count, leaf count, and root. The final chunk may be shorter than the standard chunk length. Empty resources require a separate definition; the verifier must not guess how to handle them.
A Merkle proof answers “Does this chunk belong to the given root?” It does not answer “Who published this root?” The root and leaf count must come from a trusted channel or a manifest whose signature has been verified.
Implement a strict proof verifier
Save the following as MerkleCheck.swift on the cloud Mac. First run xcrun swift --version to record the toolchain, then run xcrun swift MerkleCheck.swift. The code uses only system frameworks and requires no third-party package downloads.
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")
The script exits silently on success and returns a nonzero status on failure. Archive the exit status; do not replace exit-code checks with a search for a particular success message.
Cover tree-shape edge cases with small trees
In a three-leaf tree, the third leaf is promoted directly at the first level, so its proof needs only one sibling hash. If the generator duplicates the final node instead, it produces a different root. This is not a network error and should not be addressed with retries.
After moving the script into a test target, add at least the following checks:
- Single-leaf tree: an empty proof passes; adding any node causes failure.
- Two-leaf tree: each leaf passes in its correct position; swapping positions fails.
- Three- and five-leaf trees: the final leaf is promoted correctly; proofs with one missing or one extra node fail.
- Invalid inputs: negative indices, zero leaves, short hashes, and out-of-range indices are all rejected.
The example constructs the root using the same hash functions as the verifier, so it checks only control flow. Also generate fixed test vectors with an independent implementation and commit the chunk bytes, root, and proof as hexadecimal values. This prevents a shared bug in the generator and verifier from going unnoticed. If different positions contain identical data, chunk bytes alone cannot distinguish their identities. If position binding is required, include the index and an explicit integer encoding in the protocol.
Verify before writing
The download layer first checks the chunk length and index against the manifest, then verifies the proof. Only after verification passes should it write the chunk to the staging directory for that resource version. Once all chunks are present, check the total length and publish a completion marker. The interface must not treat “file exists” as equivalent to “resource is available.”
Proofs are untrusted input. Before parsing them, limit the response body size, node count, and hash length. A valid proof grows at most with the number of tree levels; do not accept unbounded arrays. Also limit the raw chunk size to avoid exhausting memory by loading an entire chunk before verification even begins.
Retries must use the same manifest snapshot. If a new version is discovered during a download, either terminate the old task or continue downloading the old version. Never use a new root to verify old chunks. Validation logs need only record the resource version, chunk index, and failure category; they must not expose chunk contents or access credentials.
Make regression results actionable
Run both valid and adversarial test vectors on the pinned toolchain. If valid input is rejected, first check the odd-node rule, proof order, and byte encoding. If invalid input is accepted, first check for missing validation of the index, unconsumed proof nodes, or the trusted source of the root.
Finally, inject a fault into the end-to-end workflow: change one byte in a staging chunk, then confirm that the chunk is rejected and no completion marker is created. Restore the correct chunk and confirm that the task can continue. Acceptance is not about whether “the hashing code ran to completion.” It is about ensuring that no data can enter the readable resource set without passing proof verification.
Frequently asked questions
Can the Merkle root be downloaded alongside the resources?
Yes, but authenticate the root and leaf count through a trusted channel or a verified signed manifest. If an attacker can replace both the content and root, a proof can still pass.
Should the last node on an odd level be duplicated?
This protocol promotes it unchanged without consuming a sibling hash. Duplication is a different convention; producers and verifiers must agree on one rule.
Let the data center handle your build machine
Rent a dedicated physical Mac mini and run Xcode builds and macOS CI on a machine that stays online year-round. Choose a configuration for your workload and a location that suits your team.
Choose a cloud Mac