canonical tree hashing

Chunk Size: 4 KB (4096 bytes)

Content is split into fixed 4 KB chunks (4096 bytes). Each chunk is hashed via hash_leaf(data, counter, is_root). At 56 input bytes per rate block, one 4 KB chunk requires ⌈4096/56⌉ = 74 absorptions. The last chunk may be shorter. No content-defined chunking — every boundary is deterministic.

See docs/explanation/chunk-size.md for rationale.

Leaf Hashing: hash_leaf(data, counter, is_root) → Hash

Two-pass construction separates content hashing from structural binding:

Pass 1 — content hashing:
    hasher ← Hasher::new()              (plain sponge, domain_tag = 0x00)
    hasher.absorb(data)
    base_hash ← hasher.finalize()       (32-byte sponge output)

Pass 2 — structural binding:
    state ← [0; 16]
    state[0..4] ← bytes_to_elements(base_hash)    (4 Goldilocks elements)
    state[8]    ← counter                          (chunk position, 0-based)
    state[9]    ← FLAG_CHUNK | (FLAG_ROOT if is_root)
    state ← permute(state)
    output ← elements_to_bytes(state[0..4])        (32-byte chaining value)

Two passes exist for three reasons. The sponge remains a pure hash — no tree metadata contaminates its input stream. Tree logic is layered on top via the capacity region. The base_hash is cacheable: if a chunk appears at multiple positions, the expensive 74-absorption pass runs once and the cheap 1-permutation binding runs per position.

Counter prevents chunk reordering. CHUNK flag prevents leaf/node confusion. ROOT flag distinguishes root finalization from subtree chaining.

Cost: N absorptions + 1 permutation. At 4096 bytes: 74 + 1 = 75 permutations per leaf.

Internal Node Hashing: hash_node(left, right, is_root) → Hash

state ← [0; 16]
state[9] ← FLAG_PARENT | (FLAG_ROOT if is_root)

// Absorb both children (4 + 4 = 8 elements = one full rate block)
state[0..4] += bytes_to_elements(left)     (field addition, element-wise)
state[4..8] += bytes_to_elements(right)    (field addition, element-wise)
state ← permute(state)

output ← elements_to_bytes(state[0..4])    (32-byte chaining value)

No padding needed — inputs are always exactly two 32-byte hashes (4 + 4 = 8 elements = one rate block). PARENT flag lives in the capacity region. Order matters: hash_node(A, B) ≠ hash_node(B, A).

Cost: 1 permutation per internal node.

Namespace-Aware Parent: hash_node_nmt(left, right, ns_min, ns_max, is_root) → Hash

Same as hash_node but with namespace bounds in the capacity:

state[12] ← ns_min        (minimum namespace in subtree)
state[13] ← ns_max        (maximum namespace in subtree)

When ns_min = ns_max = 0, reduces to hash_node. Only NMT uses non-zero namespace bounds.

Verifier checks: parent.ns_min ≤ left.ns_max < right.ns_min ≤ parent.ns_max (for sorted NMT).

Cost: 1 permutation (same as hash_node).

Tree Shape

Binary, left-balanced, in-order indexed:

If N = 1:     hash_leaf(data, 0, is_root=true)  is the root
If N > 1:     split = 2^(⌈log₂(N)⌉ - 1)
              left  = tree_hash(chunks[0..split],       is_root=false)
              right = tree_hash(chunks[split..N],        is_root=false)
              root  = hash_node(left, right, is_root=true)

Left-balanced: the left subtree is always a complete binary tree (power-of-2 leaves). The right subtree absorbs the remainder and recurses with the same rule.

In-order indexing: Leaves at even positions (0, 2, 4, ...). Parents at odd positions with level = trailing_ones(index). This indexing is compatible with Merkle Mountain Range append operations.

Root Finalization

Case Function Flags Value
Single-chunk file hash_leaf(data, 0, is_root=true) CHUNK | ROOT 0x05
Multi-chunk file hash_node(left, right, is_root=true) PARENT | ROOT 0x03

The ROOT flag ensures that a file's identity hash is never equal to any internal chaining value. A subtree hash with is_root=false is structurally distinct from the same tree computed as a standalone file with is_root=true.

Security Properties

Attack Defense Mechanism
Leaf/node confusion Prevented CHUNK (0x04) vs PARENT (0x02) in capacity
Chunk reordering Prevented Counter in state[8] binds position
Chunk duplication Prevented Counter distinguishes identical chunks at different offsets
Subtree substitution Prevented ROOT flag separates file identity from subtree identity
Length extension Prevented Length in state[10] (sponge) + counter (binding)
Second preimage via tree Prevented All of the above, combined

Complete Example

A 12 KB file (3 chunks):

Graph