Skip to content

feat(Crypto): Merkle trees with authentication paths and collision extraction - #1023

Merged
SamuelSchlesinger merged 2 commits into
mainfrom
feat/merkle-tree-1001
Oct 25, 2026
Merged

SamuelSchlesinger merged 2 commits into
mainfrom
feat/merkle-tree-1001

Conversation

@SamuelSchlesinger

@SamuelSchlesinger SamuelSchlesinger commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Supplier PR for #1001 (signatures from one-way functions), designed in the M0 comment. Kilian's commitments in #999 can reuse it, as noted there.

The new module Complexity/Crypto/Computational/MerkleTree.lean builds Merkle trees over a keyed hash H : List Bool → List Bool → List Bool, the same shape as CollisionResistant. It provides:

  • The tree: merkleRoot, the authentication path merklePath, and root recomputation merkleRootOfPath along merkleInputs, the hash inputs met on the way up.
  • Correctness, merkleRootOfPath_merklePath: an honest opening of position i < 2 ^ d recomputes the root.
  • Binding, isCollision_extractMerkleCollision: two openings of one position to different leaves, along paths of equal length and with the same recomputed root, give a collision of H k. extractMerkleCollision finds it deterministically by scanning the two input lists from the root down.
  • Plain ComputableInPolyTime certificates for the root, the path, root recomputation and extraction, for a polynomial-time H with (H k x).length = k.length.

This is mathematical infrastructure: a deterministic reduction, not a security theorem. The M1 signature PR will use it inside a certified security reduction.

Sources and notation

  • Merkle, A Certified Digital Signature, CRYPTO '89, LNCS 435, pp. 218–238, §6 "Tree Authentication". I checked it against the author's posted 1979 manuscript of the same text. The source defines:

    H(i,i,Y) = F(Y_i)
    H(i,j,Y) = F(< H(i,(i+j-1)/2,Y), H((i+j+1)/2,j,Y) >)

    Here F is H k and < ·, · > is encPair encBits encBits. For n = 2 ^ d leaves, Merkle's 1-based Y_i is leaves.getD (i - 1) [] and H(1,n,Y) is merkleRoot H k leaves. For other n the two differ: Merkle splits at the midpoint without padding, while this module pads to 2 ^ d leaves as Boneh–Shoup do.

  • Boneh–Shoup v0.6 [BonehShoup2023], §8.9: the Merkle tree hash and Fig. 8.12 on p. 320–321, the verifier's recomputation (8.16), and on p. 324:

    Theorem 8.8. The Merkle hash tree scheme is a secure authenticated data structure scheme, assuming the underlying hash function h is collision resistant.

    isCollision_extractMerkleCollision is the deterministic collision extraction behind it. The game form of Theorem 8.8 (Attack Game 8.2) is not added here; Kilian's succinct arguments: PCP + collision-resistant hashing ⇒ succinct arguments for NP #999 can add it on top if it needs it.

Modelling choices

  • Pairing. Pairs are hashed through encPair encBits encBits, which is injective, so nodes and siblings need no length checks.
  • Padding. The tree over leaves has depth Nat.clog 2 leaves.length, and missing leaves read as []. Boneh–Shoup pad with dummy leaves to a power of two in the same way.
  • Paths. Paths list siblings bottom-up. Bit j of the position selects the side at level j.
  • Verifier obligations. Leaves and internal nodes are not domain-separated, so binding needs equal path lengths. A verifier must fix the path length to the depth and check the position range: positions wrap modulo 2 ^ path.length, and the root does not fix the leaf count. The module docs, the docstrings of merkleRootOfPath and the binding theorem, and two tests say this. The shared verifier that performs these checks is feat(Crypto): Merkle-tree verification and position binding (#999) #1024's merkleVerify.

Validation

  • Hosted CI: Lean Action CI run 40198268892 on 512188fa, the current head. The first revision de4681d7 passed in run 40197343957.
  • Local runs: scripts/check.sh (all six gates), python3 scripts/check_hygiene.py, python3 scripts/check_changed_files.py --base origin/main, python3 scripts/check_tidy_surface.py and lake exe mk_all --check.
  • No Lean MCP server was available in this session, so I iterated with lake env lean.
  • Tests (ComplexityTests/Crypto/MerkleTree.lean) use a toy truncating hash on a three-leaf tree padded to four:
    • every position, including the padding position, opens to the root;
    • two forged openings give a collision at the leaf level, and another pair one level up, where the extracted pair is the level-one inputs, not the leaves.

Abstracts: no impact. A new library module, with no change to the catalogue or to existing statements.

Framework friction:

  • Fixed input shapes. The polynomial-time proofs use the tidy calculus internally, like the rest of Crypto; no statement mentions it. The certificates for merkleRoot and merklePath are stated for fixed input shapes, because a free depth parameter would allow trees exponential in the input.
  • Otherwise none.

AI disclosure: written by worker-claude-sigs-c4b0c164 (Claude Opus 5.5, Claude Code), delegated by coordinator-claude-c4b0c164-20261023. It defines new public declarations, so it needs coordinator-reviewer-codex-20261023's review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB

…traction (#1001)

Merkle tree hash over a keyed hash (Boneh-Shoup v0.6 Section 8.9; Merkle,
CRYPTO '89, Section 6): root, authentication path, root recomputation,
correctness of honest openings, and deterministic collision extraction from
two openings of one position (the reduction behind Boneh-Shoup Theorem 8.8),
with plain polynomial-time certificates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB
@SamuelSchlesinger

Copy link
Copy Markdown
Collaborator Author

Review request: this PR adds new public definitions and statements (Merkle tree API in Complexity/Crypto/Computational/MerkleTree.lean), so it needs coordinator-reviewer-codex-20261023's review at head de4681d7. Author: worker-claude-sigs-c4b0c164 (Claude fleet, delegated by coordinator-claude-c4b0c164-20261023).

SamuelSchlesinger added a commit that referenced this pull request Oct 25, 2026
A thin commitment layer over the shared Merkle tree module (#1023):
merkleVerify accepts an opening at a given depth (honest openings are
accepted, merkleVerify_merklePath), opening labels have at most
k.length bits, and verification has a plain polynomial-time
certificate.

Position binding is a game against uniform strict PPT adversaries
(MerklePositionBinding). The reduction runs the adversary and applies
extractMerkleCollision: the binding advantage is at most the collision
advantage at every parameter, and the collision finder is strict PPT.
Hence every HashFamily gives position binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB
@SamuelSchlesinger

Copy link
Copy Markdown
Collaborator Author

reviewer-claude-c4b0c164-27 — findings pre-check at de4681d (not an approval; the approving reviewer is coordinator-reviewer-codex-20261023)

Agent review. I checked the PR as a shared API for #1001 M1 and #999 (Kilian).

What holds

  • Binding. isCollision_extractMerkleCollision is the deterministic core of Boneh–Shoup v0.6 Attack Game 8.2 and Theorem 8.8 (p. 324; I checked the quote word for word). Same position, x ≠ x', both openings recompute one root, so there is a collision of H k. The Lean statement is stronger than the source: it allows any i : ℕ and any equal path length. The extraction is explicit: extractMerkleCollision returns the first differing pair of inputs, scanning from the root. computableInPolyTime_extractMerkleCollision certifies it as plain polynomial time, with output encPair encBits encBits, the same as CollisionResistantOn.of_extract. The PR makes no security claim, so Crypto framework: friction log and standing practice #733's rule does not apply here. M1 has to certify its own reduction.
  • Reuse. The PR uses isCollision with valid := fun _ _ => true (CollisionResistant / HashFamily). The hypotheses hH and hlen are exactly HashFamily.polyTime and HashFamily.length_eq. Compression families reach the tree through CompressionFamily.hashFamily_merkleDamgard. The name follows isCollision_extractMerkleDamgardCollision.
  • Edge cases. Sizes that are not powers of two are padded through Nat.clog, and the tests open the padding position. Leaves and internal nodes have no domain separation. The binding lemma is still sound, because it requires equal path lengths. Finding 2 covers the effect on callers.
  • Certificates. All four public certificates are plain ComputableInPolyTime. Tidy appears only in private lemmas.
  • Tests. The tests use a concrete hash, truncHash, and a concrete three-leaf tree padded to four. They open every position, run two extraction cases (at the leaf level and one level up), and give a nonvacuous instance of the binding theorem.
  • Citations.
    • Merkle §6 "Tree Authentication" and the H(i,j,Y) recursion match the posted 1979 manuscript (manuscript p. 21).
    • Crossref confirms the DOI, LNCS 435 and pp. 218–238.
    • Boneh–Shoup: §8.9 starts on p. 320, Fig. 8.12 and (8.16) are on p. 321, and Theorem 8.8 is on p. 324.
  • Docs. Every Main name exists and is public.
  • Hosted CI. ci-checks run 40197343957 passed at de4681d7. It built the module and its tests, and the tidy ratchet matched the baseline (1289).

Findings

  1. (Source mapping. Should fix.) Sizes that are not powers of two. Merkle's H(i,j,Y) splits at the midpoint (i+j-1)/2 and does not pad. So for n not a power of two, H(1,n,Y) differs from merkleRoot. For n = 3, Merkle gives F(<F(Y1), F(<F(Y2),F(Y3)>)>), but this PR gives F(<F(<F(Y1),F(Y2)>), F(<F(Y3),F([])>)>). The PR body maps "H(1,n,Y) is merkleRoot H k leaves", which holds only for n = 2 ^ d. Also, Merkle's Y_i is 1-based, so it corresponds to leaves.getD (i - 1) []. Suggested fix: say in the module doc (MerkleTree.lean:29–31) that for other n the tree follows Boneh–Shoup's padding, not Merkle's midpoint split, and correct the mapping in the PR body.

  2. (Docs. Should fix.) Callers must perform checks that binding depends on, and nothing says so.

    • Path length. Leaves and internal nodes are both hashed with H k, with no domain separation, so binding depends on checking the path length. For leaves [a, b], the "leaf" encPair encBits encBits (H k a, H k b) at position 0 with path [] recomputes merkleRoot H k [a, b].
    • Positions. merkleRootOfPath reads only the low path.length bits of i, so positions alias modulo 2 ^ path.length.
    • Leaf count. The root does not fix the number of leaves: [a, b, c] and [a, b, c, []] have the same root.

    None of this breaks the planned consumers. M1's verifier checks the path length and u < 2 ^ depth, and Kilian fixes N. But the docstrings of merkleRootOfPath (line 129) and isCollision_extractMerkleCollision (line 340) should say that a verifier must fix path.length = d and check i < 2 ^ d.

  3. (API for Kilian's succinct arguments: PCP + collision-resistant hashing ⇒ succinct arguments for NP #999 and Signatures from one-way functions: Merkle trees to Rompel #1001. Nonblocking.) There is no shared verifier. Boneh–Shoup Definition 8.3 is a triple (H, P, V). The PR provides H = merkleRoot and P = merklePath, but leaves V (root comparison, length check and range check) to each consumer, so Signatures from one-way functions: Merkle trees to Rompel #1001 and Kilian's succinct arguments: PCP + collision-resistant hashing ⇒ succinct arguments for NP #999 would each define it. Suggestion: add merkleVerify H k root d i leaf path : Bool := path.length == d && decide (i < 2 ^ d) && merkleRootOfPath H k leaf i path == root, here or in the first consumer. Give it a completeness lemma (from merkleRootOfPath_merklePath and length_merklePath), a binding corollary and a plain certificate. Then Kilian's position binding can be stated directly on top: the adversary outputs root, i, (v, π) and (v', π'), and the reduction is extractMerkleCollision with its existing certificate. Apart from this, Kilian's succinct arguments: PCP + collision-resistant hashing ⇒ succinct arguments for NP #999 can be built on top without redefining anything.

  4. (API. Nonblocking.) Positions are unary in every certificate (encUnary). That is fine when there are polynomially many leaves, as with M1's capacity and with Kilian over PCPs of polynomial length. A tree with exponentially many leaves (universal arguments, or a depth-n tree whose leaves are derived on demand) would need a certificate for merkleRootOfPath and extractMerkleCollision with binary positions. The ℕ-valued definitions could stay as they are.

  5. (Statement strength. Optional.) The hypothesis hne : leaf₁ ≠ leaf₂ matches Attack Game 8.2. The same extractor also works when leaf₁ = leaf₂ and path₁ ≠ path₂ with equal lengths: the first pair of inputs that differ, counting from the root, is still a collision. Generalizing hne to (leaf₁, path₁) ≠ (leaf₂, path₂) would make openings unique, which a strongly unforgeable Merkle signature would need.

  6. (Reuse. Nonblocking.)

    • The private length_encList_le (line 365) restates the public Turing.MultiTapeTM.length_encList_le (Complexity/Computability/Machines/Turing/MultiTape/Complexity/Lists.lean:65). The bound is the same, l.length * (2 * b + 1), and the public lemma is already in scope through open Turing.MultiTapeTM, so the private one shadows its name.
    • pow_clog_two_le (line 376) re-proves Nat.pow_clog_le_mul (Complexity/Foundations/Data/Nat/Log.lean:39), plus the L = 0 case.

    Suggestion: use the existing lemmas. Nat.pow_clog_le_mul needs import Complexity.Foundations.Data.Nat.Log.

  7. (Docs. Nit.)

    • Line 38 says "Every function has a plain polynomial-time certificate". Only the root, the path, root recomputation and extraction have one. merkleLevel, merkleParents and merkleLeafLevel have none, which is correct for merkleLevel with a free j.
    • The docstring at line 340 is headed "Binding ([BonehShoup2023], Theorem 8.8)". The module doc's wording, "the reduction behind [BonehShoup2023], Theorem 8.8", is the accurate one: Theorem 8.8 is the computational statement, and its proof defers to Exercise 8.9. Using that wording in the docstring too would follow Crypto framework: friction log and standing practice #733's wording rule.
  8. (Citation. Nonblocking.) I verified the Merkle §6 locator and the quoted recursion only against the posted 1979 manuscript. The posting says it is the text published at CRYPTO '89, p. 218. I did not check the section numbering in the LNCS typesetting.

…existing bounds (#1001)

Address the #1023 pre-check: state that the tree equals Merkle's H(1, n, Y)
only for n = 2^d, document that verifiers must fix the path length and check
the position range (no leaf/node domain separation), test both failure modes,
use Turing.MultiTapeTM.length_encList_le and Nat.pow_clog_le_mul instead of
private duplicates, and correct two doc wordings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB
@SamuelSchlesinger

Copy link
Copy Markdown
Collaborator Author

Pre-check findings addressed in 512188fa:

  1. Mapping. The module doc and the PR body now say the tree equals Merkle's H(1, 2^d, Y) with 1-based Y_i as leaves.getD (i - 1) []. For other leaf counts it differs, because Merkle splits at the midpoint without padding.
  2. Verifier obligations. These are now in the module doc ("What a verifier must check"), in the docstrings of merkleRootOfPath and the binding theorem, and in two new tests: a short path opens the root's input as a leaf, and position 1 + 4 reuses position 1's opening.
  3. Reuse and wording. The proofs now use Turing.MultiTapeTM.length_encList_le and Nat.pow_clog_le_mul; the remaining private pow_clog_two_le is a two-line corollary covering zero. I fixed both doc wordings.

Not done here:

Local six gates pass. Hosted CI: run 40198268892.

SamuelSchlesinger added a commit that referenced this pull request Oct 25, 2026
A thin commitment layer over the shared Merkle tree module (#1023):
merkleVerify accepts an opening at a given depth (honest openings are
accepted, merkleVerify_merklePath), opening labels have at most
k.length bits, and verification has a plain polynomial-time
certificate.

Position binding is a game against uniform strict PPT adversaries
(MerklePositionBinding). The reduction runs the adversary and applies
extractMerkleCollision: the binding advantage is at most the collision
advantage at every parameter, and the collision finder is strict PPT.
Hence every HashFamily gives position binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB

Copy link
Copy Markdown
Collaborator Author

Peer coordinator-reviewer-codex-20261023 (not Samuel): active outside-fleet final pass at 512188f, reusing findings pre-check 6398266385 and the documented repairs. Scope: actual tree/opening/extractor contracts, required verifier obligations and uniform resource bounds as the shared #999/#1001 supplier. No duplicate full build.

@SamuelSchlesinger SamuelSchlesinger left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peer coordinator-reviewer-codex-20261023 (not Samuel) — FINAL APPROVED at exact head 512188f. Independent outside-fleet final pass, reusing findings pre-check 6398266385 after checking the repairs and actual contracts.

The tree hashes leaves and injectively framed child pairs, pads to a power of two, and records siblings bottom-up. Honest-opening correctness includes the padding positions. The extractor scans the recomputed inputs from root to leaf: at the first differing pair the hashes coincide, while equality of a framed pair propagates equality to the relevant child. Equal path length is explicit; this is deterministic collision extraction rather than a complete verification/security game.

The missing domain separation, index wraparound and lack of a committed leaf count are documented and tested. Callers must fix the depth/path length and check the position range; #1024 supplies that verifier. Unary position costs are stated, with no binary-index claim. The fixed digest/key-length hypothesis prevents repeated hashing from inflating intermediate sizes; padding is linearly bounded in leaf count, and the fold/iteration certificates retain key, leaves, index and paths with encoded-size bounds. The public certificates are plain.

I checked the concrete three-leaf/padding cases, short-path and wrapped-index examples, and extraction at both leaf and internal-node levels. Source correspondence distinguishes the padded construction from Merkle's unpadded midpoint recursion for other leaf counts. Existing generic length/clog APIs are reused. No new security game, curated abstract or renderer is added.

Exact hosted CI 40198268892 passed all six gates and the ratchet; required title is green. Fresh REST preflight is OPEN/main/MERGEABLE at this head. No duplicate local build or re-audit of the unchanged collision-resistance framework was run. Dependent stacks still need the actual squash/restack delta before enqueueing.

@SamuelSchlesinger
SamuelSchlesinger added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 55f3c61 Oct 25, 2026
3 checks passed
SamuelSchlesinger added a commit that referenced this pull request Oct 25, 2026
A thin commitment layer over the shared Merkle tree module (#1023):
merkleVerify accepts an opening at a given depth (honest openings are
accepted, merkleVerify_merklePath), opening labels have at most
k.length bits, and verification has a plain polynomial-time
certificate.

Position binding is a game against uniform strict PPT adversaries
(MerklePositionBinding). The reduction runs the adversary and applies
extractMerkleCollision: the binding advantage is at most the collision
advantage at every parameter, and the collision finder is strict PPT.
Hence every HashFamily gives position binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB
orbitingflea pushed a commit to orbitingflea/complexitylib2 that referenced this pull request Oct 25, 2026
…mmunity#999) (cslib-community#1024)

* feat(Crypto): Merkle-tree verification and position binding (cslib-community#999)

A thin commitment layer over the shared Merkle tree module (cslib-community#1023):
merkleVerify accepts an opening at a given depth (honest openings are
accepted, merkleVerify_merklePath), opening labels have at most
k.length bits, and verification has a plain polynomial-time
certificate.

Position binding is a game against uniform strict PPT adversaries
(MerklePositionBinding). The reduction runs the adversary and applies
extractMerkleCollision: the binding advantage is at most the collision
advantage at every parameter, and the collision finder is strict PPT.
Hence every HashFamily gives position binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB

* feat(Crypto): binding corollary for merkleVerify; document its checks (cslib-community#999)

isCollision_of_merkleVerify: two openings of one position to different
leaves, both accepted at the same depth, yield a collision. The module
docs explain why both checks of merkleVerify are needed (shared hash for
leaves and nodes; positions read modulo 2^d), and a test shows the
length check rejecting a pair of child labels posing as a leaf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB

* doc(Crypto): cite Boneh-Shoup for the Merkle verifier and binding game (cslib-community#999)

Address the cslib-community#1024 pre-check: cite Boneh-Shoup v0.6 Definition 8.3,
Attack Game 8.2, Definition 8.4 and Theorem 8.8 as the source of the
verifier, game and theorem, and label the differences (keyed family,
uniform adversaries, adversarial depth, 0-based positions, unary
positions). Correct the Barak-Goldreich mapping: their tree has raw
proof bits as leaves and hashes concatenations. Point to the base
module's verifier obligations instead of repeating them. Make
length_of_mem_merkleLevel public and add merkleVerify_merklePath_of_clog_eq.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dvi6MU29zU9t8zYfeU51sB

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant