「Merkle tree は 100 万個のトランザクションをひとつの root hash にコミットでき、特定の 1 件が含まれることは ~20 個のハッシュだけで証明できる」 — 説明はだいたいここで止まる。この記事は、その図そのもの をブラウザで動かせるようにした話。
leaf を編集すると、その leaf から root までのハッシュが連鎖的に赤く変わる。inclusion proof(特定の leaf が含まれていることを root だけしか持たない検証者に証明する道具)も画面右にリアルタイムで生成され、検証結果が ✓ / ✗ で切り替わる。
🌳 デモ: https://sen.ltd/portfolio/merkle-viz/
📦 GitHub: https://github.com/sen-ltd/merkle-viz
純粋ロジック ~200 行 + DOM/SVG ~150 行。ビルド不要、依存なし。ハッシュは crypto.subtle.digest("SHA-256", …) を使うので、同じ merkle.js がブラウザでも Node 18+ でも動く。node --test で 17 ケースのユニットテスト付き。
Merkle tree が解いている問題
リスト(トランザクション・ログ・ファイル等、何でもいい)に対して、ひとつの短い指紋(fingerprint)を作る。ただし以下の性質を満たす:
- 任意の項目を 1 個でも書き換えると、指紋が変わる
- 任意の項目について、「これがリストに含まれていた」ことを 指紋しか持たない検証者 に証明できる。リスト全体を送る必要なし
- 証明の生成・検証はどちらも O(log n) で済む
素朴な「全部連結して sha256 する」では (1) は満たせるが (2) で詰む(検証者にリスト全体が必要)。Merkle tree は 3 つ全部を満たす。
構成は機械的:
level 0: H(L0) H(L1) H(L2) H(L3) ← 各 leaf のハッシュ
level 1: H(H(L0)||H(L1)) H(H(L2)||H(L3)) ← 2 つずつまとめてハッシュ
level 2: H( level1_left || level1_right )
=
ROOT
H は SHA-256、|| は byte 連結(hex 文字列の連結ではない、これは罠)。leaf 数が奇数の level では、本実装は最後のノードを 複製してそれ自身とペアにする(Bitcoin の合意ルールが採用している規約)。Certificate Transparency は奇数残りをそのまま上に上げる別規約を使う、Ethereum の Patricia trie は更に別物 — 規約はどれかひとつ選んで明文化して使い続ければいい。
inclusion proof は単純に「leaf から root まで上っていく途中で、各 level の sibling のハッシュ」を集めたもの。leaf 4 個なら proof は 2 個、1024 個なら 10 個、100 万個なら 20 個。
純粋ロジック側
merkle.js は検証者が必要とする 4 関数 + 可視化用の補助 3 関数を export する。すべて async(crypto.subtle.digest が async なので)。
const enc = new TextEncoder();
export async function sha256Hex(bytes) {
const buf = bytes instanceof Uint8Array ? bytes : enc.encode(bytes);
const digest = await crypto.subtle.digest("SHA-256", buf);
return bytesToHex(new Uint8Array(digest));
}
export async function hashLeaf(text) {
return sha256Hex(text);
}
export async function hashPair(leftHex, rightHex) {
const buf = new Uint8Array(64);
buf.set(hexToBytes(leftHex), 0);
buf.set(hexToBytes(rightHex), 32);
return sha256Hex(buf);
}
hashPair で間違えやすいのが「子ハッシュを byte で連結 する」点。hex 文字列のまま + で繋ぐと別物の root になり、別実装で作った proof は通らなくなる。テストで明示的に固定:
test("hashPair concatenates child bytes (not hex strings)", async () => {
const left = await hashLeaf("L");
const right = await hashLeaf("R");
const got = await hashPair(left, right);
// 参照実装: byte 列を連結してから sha256
const concat = Buffer.concat([
Buffer.from(left, "hex"),
Buffer.from(right, "hex"),
]);
assert.equal(got, createHash("sha256").update(concat).digest("hex"));
});
ツリー構築は素直なボトムアップ:
export async function buildTree(leaves) {
if (!leaves.length) return { levels: [[]], root: null };
const leafHashes = await Promise.all(leaves.map(hashLeaf));
const levels = [leafHashes];
while (levels[levels.length - 1].length > 1) {
const cur = levels[levels.length - 1];
const next = [];
for (let i = 0; i < cur.length; i += 2) {
const left = cur[i];
const right = i + 1 < cur.length ? cur[i + 1] : cur[i]; // 奇数残りは複製
next.push(await hashPair(left, right));
}
levels.push(next);
}
return { levels, root: levels[levels.length - 1][0] };
}
可視化のため全 level を保持しているが、root しか必要ない実装なら各 level を消費後に捨てればよい。
proof の生成と検証
getProof(levels, i) は leaf i から root まで上りながら sibling を集める:
export function getProof(levels, index) {
if (!levels.length || !levels[0].length) return [];
const proof = [];
let i = index;
for (let lvl = 0; lvl < levels.length - 1; lvl++) {
const layer = levels[lvl];
const isRight = i % 2 === 1;
const sibIdx = isRight ? i - 1 : i + 1;
const sibling = sibIdx < layer.length ? layer[sibIdx] : layer[i]; // 奇数残りは自分自身
proof.push({ hash: sibling, side: isRight ? "left" : "right" });
i = Math.floor(i / 2);
}
return proof;
}
非自明なポイントが 2 つ:
-
sideが必須。running hash と sibling を連結する順序が結果を変える。running が左の子ならrunning || sibling、右の子ならsibling || running。これを忘れると、検証は静かに全部 reject する -
奇数残りの leaf は自分自身を sibling にする。3-leaf tree の level 0 は
[H(L0), H(L1), H(L2)]、level 1 は[H(H(L0)||H(L1)), H(H(L2)||H(L2))]。L2 の proof の最初のステップは sibling=L2 自身のハッシュ、になる
検証は逆向き:
export async function verifyProof(leafText, proof, expectedRoot) {
let running = await hashLeaf(leafText);
for (const step of proof) {
if (step.side === "left") {
running = await hashPair(step.hash, running);
} else {
running = await hashPair(running, step.hash);
}
}
return { ok: running === expectedRoot, computedRoot: running };
}
検証者は他の leaf を一切見ない。候補 leaf をハッシュし、与えられた sibling と pair-hash を順に再生し、最終値を信頼している root と比較するだけ。これで O(n) のデータセットへの inclusion を O(log n) のハッシュで証明できる。
改ざん伝播の可視化
Merkle tree のデモで一番効くのは「leaf を 1 byte 変えると root が変わる」を 見える ようにすること。可視化ツールは「baseline tree」と「current tree」をメモリに保持し、描画時に diff を取る:
export function diffLevels(a, b) {
const changed = new Set();
for (let lvl = 0; lvl < Math.max(a.length, b.length); lvl++) {
const la = a[lvl] || [], lb = b[lvl] || [];
const w = Math.max(la.length, lb.length);
for (let i = 0; i < w; i++) {
if (la[i] !== lb[i]) changed.add(`${lvl}:${i}`);
}
}
return changed;
}
leaf を 1 個編集すると、changed set には leaf から root への 1 本道だけ が入る。proof が依存している sibling 側は無傷。テストで形を固定:
test("diffLevels finds exactly the path of changed nodes", async () => {
const before = await buildTree(["a", "b", "c", "d"]);
const after = await buildTree(["a", "b", "c!", "d"]);
const changed = diffLevels(before.levels, after.levels);
// 期待: leaf 2, 親 (1,1), root (2,0)
assert.ok(changed.has("0:2"));
assert.ok(changed.has("1:1"));
assert.ok(changed.has("2:0"));
// sibling 側は無傷
assert.ok(!changed.has("0:0")); assert.ok(!changed.has("0:1"));
assert.ok(!changed.has("0:3")); assert.ok(!changed.has("1:0"));
});
これがそのまま「改ざんは O(log n) で root に伝播する」というコード化。SVG 側は changed set を歩いて該当ノードを赤く描く。Merkle tree の解説でみんな描きたいのに描かれない図、になる。
2 ファイル構成にした理由
merkle.js は DOM 参照ゼロ・グローバル使用ゼロ・モジュール状態ゼロ。export はすべて純粋(または async-pure)。これで node --test がそのまま読める:
$ npm test
✔ sha256Hex matches node:crypto for ASCII input
✔ hashLeaf is deterministic and matches sha256(text)
✔ hashPair concatenates child bytes (not hex strings)
✔ buildTree on 4 leaves yields a 3-level tree
✔ buildTree duplicates the last leaf when count is odd (Bitcoin style)
✔ proofs verify against the real root for every leaf
✔ proof verification fails if a single character of the leaf changes
✔ proof verification fails if a sibling hash is swapped
✔ diffLevels finds exactly the path of changed nodes
…
ℹ tests 17 ℹ pass 17 ℹ fail 0
script.js は DOM/SVG だけを担当: leaf エディタ、SVG ツリー描画、proof パネル。ハッシュ計算は持たず、必ず merkle.js 経由。これで本番コードとテスト対象が同じものになる。
crypto.subtle.digest は近代ブラウザと Node 18+ で挙動が同じなので、polyfill も shim も要らない。代償は async-everywhere になることだけ、というのは妥当なトレードオフ。
実世界での Merkle tree
- Bitcoin のブロックヘッダ は、ブロック内の全トランザクションに対する root hash を持つ。SPV (Simplified Payment Verification) ウォレットはまさにこの inclusion proof で動く: サーバが「トランザクション + Merkle proof」を返し、ウォレット側はすでに持っているブロックヘッダだけで検証する。2008 年の論文の中核設計
- Git の tree オブジェクト はディレクトリ内の blob と再帰的な sub-tree をハッシュする。完全な balanced binary tree ではない(content-addressable graph)が、性質は同じ: ファイルを 1 byte 変えると root commit のハッシュまで全部変わる
- Certificate Transparency log は CA が発行した全証明書をコミットする。ブラウザは証明書を信頼する前に、log root に対する Merkle inclusion proof で「ちゃんと log されているか」を検証する。規約は微妙に違う(leaf 複製しない、domain separation あり)が、数学は同じ
- Content-addressable storage の IPFS や Plan 9 系も Merkle DAG でアドレス指定。同じアイデアの別トポロジー
共通する直感は「ハッシュは合成可能」というだけ。ハッシュのハッシュもまた指紋であり、subtree 全体の指紋を root に持つツリーが作れる。これが見えれば残りは配管作業。
意図的に省いたこと
可視化ツールは小さく保つことを優先した。本番実装が気にする以下は省いている:
-
Domain separation — 本番の Merkle tree は leaf hash と internal-node hash で異なる prefix(
0x00vs0x01)を使い、検証者を「内部ノードを leaf として騙す」second-preimage 攻撃から守る。CT や最近の Bitcoin 提案はやっている。デモは説明を短くするためにやっていない -
streaming 構築 —
buildTreeは全 level をメモリに保持する。1 億エントリの log なら、親 level を on-the-fly に計算しながら子 level を捨てるべき - proof のシリアライズ形式 — 実世界の proof は決まった byte レイアウト(CT は RFC 6962、Bitcoin はプロトコルの compact format)に直列化する。この可視化ツールはプレーンな JS object のまま扱う
本番投入するなら RFC 6962 (CT) や Bitcoin developer reference を読むこと。可視化ツールのデフォルトを信用してはいけない。
触ってみる
https://sen.ltd/portfolio/merkle-viz/ は 4 leaves(送金トランザクション風のテキスト)でスタートする。leaf 2 の tamper ボタンを押すと、典型デモが見える: leaf 2 の input が赤くなり、SVG 上で leaf 2・level 1 の親・root が赤枠に変わり、上のヘッダの root hash も切り替わる。右側の inclusion proof は 依然として ✓(current tree 側で sibling も再計算されるので、proof は新しい root に対して通る)。次に "tamper with the leaf I'm verifying" の details パネルを開いて、leaf テキストと違う内容を入力してみる。そこで初めて検証が ✗ になる — 候補 leaf がツリーのどの leaf にも対応していないので。
ソース: https://github.com/sen-ltd/merkle-viz — MIT、合計 ~350 行、17 ユニットテスト、ビルド不要。読むなら merkle.js から。残りは表示。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つです。他のツールは portfolio 一覧 から。
