iOSのオフラインリソースをチャンク単位でダウンロードする場合、各チャンクの検証に通っていても、組み立てたデータが誤ったバージョンになることがあります。別のマニフェストに属するチャンクをクライアントが受け入れていたり、ノード数が奇数の階層で生成側と検証側が異なる規則を使っていたりするためです。クラウドMac上の固定したSwift環境で検証器を動かし、まず小さな木でプロトコルの適合性を確認してから実際のダウンロード処理に組み込めば、ダウンロード完了後に全体のハッシュを一度だけ比較するよりも問題を切り分けやすくなります。
木の規則と信頼できる入力を固定する
本記事ではSHA-256を使用します。葉は H(0x00 || チャンクの生バイト列)、内部ノードは H(0x01 || 左のハッシュ || 右のハッシュ) で計算し、接頭辞で2種類の入力を区別します。ハッシュは生の32バイトを連結し、16進文字列は連結しません。
各階層でノードが1つ余った場合は、自身を複製せず、そのまま上の階層へ繰り上げます。証明は葉からルートへの順に並べ、実在する兄弟ノードだけを含めます。左右の位置は、葉のインデックスと現在の階層のノード数から導きます。
マニフェストには、リソースのバージョン、チャンク長、総バイト数、葉の数、ルートをひも付ける必要があります。最後のチャンクは標準のチャンク長より短くてもかまいません。空のリソースの扱いは別途定義し、検証器側で推測しないようにします。
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")
成功時は何も出力せずに終了し、失敗時は非ゼロの終了ステータスを返します。実行結果は終了ステータスを保存して記録し、特定の成功メッセージを検索する方法で終了コードの確認を代用しないでください。
小さな木で構造上の境界条件を網羅する
葉が3つの木では、3番目の葉が最初の階層でそのまま繰り上げられるため、兄弟ハッシュは1つだけ必要です。生成側が末尾ノードを複製すると、ルートは別の値になります。これはネットワーク障害ではなく、再試行で解決すべき問題でもありません。
スクリプトをテストターゲットへ移したら、少なくとも次の検査を追加します。
- 葉が1つの木:空の証明は受け入れ、任意のノードを追加した証明は拒否する。
- 葉が2つの木:左右の葉をそれぞれ検証でき、位置を入れ替えると失敗する。
- 葉が3つおよび5つの木:末尾の葉が正しく繰り上げられ、証明ノードが1つ不足していても1つ余っていても失敗する。
- 不正な入力:負のインデックス、葉の数がゼロ、長さが不足したハッシュ、範囲外のインデックスをすべて拒否する。
この例では同じハッシュ関数群でルートを構築しているため、確認できるのは制御フローだけです。独立した実装で固定のテストベクトルも生成し、チャンクのバイト列、ルート、証明を16進値でリポジトリに登録してください。生成器と検証器が同じ誤りを共有することを防ぐためです。異なる位置にまったく同じ内容がある場合、チャンクのバイト列だけでは両者を区別できません。位置へのひも付けが必要なら、インデックスと明確な整数エンコーディングをプロトコルに組み込みます。
書き込み前に検証する
ダウンロード層では、まずマニフェストに記載されたチャンク長とインデックスを照合し、その後に証明を検証します。検証に通ったチャンクだけを、そのリソースバージョン用の一時保存ディレクトリに書き込みます。すべてのチャンクがそろったら総バイト数を確認し、完了マーカーを公開します。画面側で「ファイルが存在する」ことを「リソースが利用可能である」ことと同一視してはいけません。
証明は信頼できない入力です。解析前にレスポンスボディのサイズ、ノード数、ハッシュ長を制限します。有効な証明のノード数は木の階層数に応じて増える程度なので、無制限の配列を受け入れてはいけません。元のチャンクのサイズにも上限を設け、チャンク全体を読み込んだ時点で、検証前にメモリを使い果たすことがないようにします。
再試行では、必ず同じマニフェストのスナップショットを使い続けます。ダウンロード中に新バージョンを検出した場合は、旧タスクを終了するか、旧バージョンのダウンロードを継続します。新しいルートで古いチャンクを検証してはいけません。受け入れ確認用のログには、リソースバージョン、チャンクのインデックス、失敗の分類だけを記録し、チャンクの内容やアクセス認証情報は出力しません。
原因を説明できる回帰テスト結果を残す
固定したツールチェーンで、正常系のテストベクトルと悪意ある入力のテストベクトルをそれぞれ実行します。正しい入力が拒否された場合は、まずノード数が奇数の階層での規則、証明の順序、バイトエンコーディングを確認します。不正な入力が受け入れられた場合は、インデックス、未消費の証明ノード、ルートの信頼できる取得元の確認が抜けていないかを優先して調べます。
最後に、エンドツーエンドの障害注入テストを行います。一時保存したチャンクの1バイトを書き換え、そのチャンクが拒否され、完了マーカーが生成されないことを確認します。その後、正しいチャンクに戻し、タスクを継続できることを確認します。合格基準は「ハッシュ計算のコードが最後まで動いたこと」ではなく、証明の検証に通っていないデータが、読み取り可能なリソース群に一切入らないことです。
よくある質問
Merkleルートはリソースと一緒に取得できますか?
取得できますが、ルートと葉の数は信頼できる経路、または署名を検証したマニフェストで認証する必要があります。内容とルートを同時に置き換えられると、証明が通る場合があります。
奇数階層の最後のノードは複製しますか?
本文の規則では、そのまま上の階層へ繰り上げ、兄弟ハッシュは消費しません。自身を複製する方式とは異なるため、生成側と検証側で規則を統一してください。
ビルド環境をデータセンターに
専有の物理 Mac mini をレンタルして、Xcode ビルドや macOS CI を365日稼働する専用マシンで実行できます。用途に合わせて構成を選び、チームの拠点に近いロケーションをお選びください。
クラウドMacを選ぶ