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 根可以和資源一起下載嗎?
可以一起傳輸,但必須透過可信管道或已驗證簽章的清單,認證根及葉節點數量。若內容與根能被同時替換,證明仍可能通過。
奇數層最後一個節點需要複製自身嗎?
本文協定將該節點直接提升,不消耗相鄰節點的雜湊。複製自身是另一種規則,產生端與驗證端必須一致,不能混用。