docs: RFC 001 — formal verification of ICS23 - #421
Open
zmanian wants to merge 67 commits into
Open
Conversation
Proposes a mechanized Lean 4 model of the verifier with machine-checked soundness theorems (existence binding, non-existence soundness, and a decidable ProofSpec well-formedness certificate), connected to the Go and Rust implementations via a differential oracle, Kani harnesses, and an optional refinement proof through hax or coq-of-rust. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
PR author is not in the allowed authors list. |
Begins executing RFC 001. Adds a dependency-free Lean 4 model of the ICS23
existence verifier mirroring rust/src/{ops,verify,api}.rs:
- Types/Ops/Verify/Specs: proto types, hashing ops (abstract hash family,
no collision-resistance assumption), the existence verifier, and the three
shipped specs (IAVL/Tendermint/SMT).
- Soundness: a decidable WellFormed predicate capturing the side conditions
for binding, with machine-checked certificates that all three shipped specs
satisfy it (Theorem C); the inner-preimage cancellation lemma; and the
precise statement of Theorem A (existence binding), proof in progress.
- docs/verification/properties.md: Phase 0 property catalogue, modeling
assumptions, and the regression corpus to encode.
Theorem A is the only sorry; nothing proved depends on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NonExist.lean: faithful model of verify_non_existence and its padding/
neighbor helpers (ensure_{left,right}_most, ensure_left_neighbor,
{left,right}_branches_are_empty, get_padding, order_from_padding), with
panic sites modeled as sound rejections.
- Corpus.lean: spec-level invariant violations (A2 domain separation,
A3 child-size/prefix-window, C malformed specs, depth bounds) encoded as
machine-checked accept/reject facts closed by decide.
Completes the executable verifier model (existence + non-existence). Theorem A
remains the only sorry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds fully-proved building blocks toward existence binding: - hashCollision_of: package a witnessed clash. - applyInner_inj: one inner step is injective in its child up to an explicit collision. - applyPath_sameops_inj: folding a shared op-list over two inputs to the same root forces the inputs equal, up to a collision (the inductive backbone). Theorem A remains the only sorry; the remaining gap is leaf injectivity (A1) and the differing-path case (positional unambiguity, A3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
existence_binding_noPrefix_sameshape fully proves Theorem A for the SMT/JMT leaf shape (NoPrefix length) when two proofs share tree structure: a value-swap forgery on one key forces a hash collision. Exercises the full chain — root extraction (verifyExistence_root), path-fold injectivity, leaf-image cancellation, and NoPrefix leaf-encoding injectivity — with no new sorry. The general existence_binding remains the only sorry; what's left is the varint self-delimiting case (length-prefixed specs) and the differing-path case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Varint.lean: varintDecode + varintDecode_roundtrip prove the varint length prefix is self-delimiting (varintEncode_append_inj), giving leaf-encoding injectivity (A1) for length-prefixed specs; doLength_varProto_inj. - Existence.lean: generalize binding to existence_binding_sameshape (any injective length op), with corollaries for NoPrefix (SMT/JMT) and VarProto (IAVL/Tendermint). The value-swap forgery on a shared tree shape provably forces a hash collision for all three shipped leaf shapes. No new sorry. The general existence_binding's only remaining gap is the differing-path case (A3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds machine-checked facts over the SMT inner spec exercising the
non-existence verifier's padding, ordering, and empty-branch placeholder logic
(order_from_padding, is_left_step, ensure_{left,right}_most,
left_branches_are_empty) — the Dragonberry-class surface. Mirrors the Rust
check_empty_branch test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .github/workflows/lean.yml: build all proofs on lean/ changes and guard that exactly one (expected) sorry remains. - properties.md: proof status and the precise remaining obligations (general Theorem A differing-path case, Theorem B tree semantics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NonExistSound.lean: precise statement of Theorem B (non-existence soundness) staged with sorry, plus proved bytesLt_irrefl and bytesLt_ne (the lexicographic ordering facts the neighbor checks rely on). - Wire into root module; bump CI sorry-guard to the two whitelisted goals (general existence_binding, nonexistence_sound). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fully-proved Theorem B infrastructure:
- bytesLt_trans: lexicographic order is transitive.
- verifyNonExistence_{left,right}: extract the neighbor existence proofs and
the strict ordering of each neighbor key against the absent key.
- verifyNonExistence_neighbors_ordered: a two-sided non-existence proof brackets
the key strictly (left key < right key) — a verified consistency property.
Leaves the tree-semantics core of nonexistence_sound as the only Theorem B sorry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Sha256.lean: pure-Lean SHA-256, validated against the digests embedded in rust/src/ops.rs (food, foobar, empty) via native_decide. - Executable.lean: concreteHash : HashFn plus end-to-end runs of the verifier — honest IAVL proofs (leaf-only and one-inner) verify; value-swap and wrong-tree-shape forgeries are refuted by computation. Makes the abstract model runnable and seeds the Phase 2a differential oracle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Builds a concrete 2-leaf Tendermint tree with SHA-256, proves non-membership of a key strictly between the leaves, confirms both leaves hash to one root, and shows the verifier refuses to prove non-membership of an existing key — the Theorem B property demonstrated by computation (native_decide). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three CBMC-verified harnesses (rust/src/kani_proofs.rs, cfg(kani), invisible to normal builds; cargo kani reports 3/3 successful): - ensure_inner prefix-bound i32 arithmetic is overflow-free under well-formed bounds (with a noted finding: an adversarial enormous child_size could overflow it); - get_padding's idx*child_size / child_size*(n-1-idx) products are overflow-free; - left_branches_are_empty slice accesses are always in bounds — the Dragonberry-class out-of-range-slice surface. Adds .github/workflows/kani.yml (official kani action) and declares the kani cfg in Cargo.toml so normal builds stay warning-free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add right_branches_slice_in_bounds_binary harness (4/4 Kani harnesses now verified). Covers the suffix slice access for binary specs (all supported stores). - Document two findings surfaced by the work: F1 (i32 prefix-bound overflow for a malformed enormous child_size) and F2 (left/right empty-branch asymmetry — right_branches_are_empty can index a child_size-long suffix out of bounds for ternary+ specs; latent since no supported store is ternary). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maps every verifier function (ops/verify/api, existence + non-existence) to its Lean model counterpart and the machine-checked results about it (proved / in-progress / Kani-verified / corpus-exercised), plus the executable layer and Kani harnesses. Keeps the verification maintainable as the code evolves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Working the differing-path case of general existence_binding surfaced a structural finding, now substantiated by computation in Executable.lean: for the Tendermint spec, the node preimage [1] || A || B is accepted by ensure_inner both as a left-child step (child A, sibling B in suffix) and a right-child step (child B, sibling A in prefix), producing the SAME node hash from DIFFERENT children. So the spec checks alone do not pin a node's recursive child, and general binding cannot reduce to collision resistance against an arbitrary hash — it needs an injective/opaque hash model or the per-store prefix structure the model abstracts (e.g. IAVL ensure_inner_prefix). Documented as F3; this is the precise obstacle to closing general existence_binding (the same-shape theorem sits below it). No evidence of a live exploit on the shipped stores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document in the proof that the general theorem is open due to the positional ambiguity (F3), which is a model-refinement question (injective/opaque hash model or per-store prefix structure), not a tactic gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lve F3
Per the chosen direction (re-include IAVL prefix checks to try to pin node
position): IavlPrefix.lean faithfully models ensure_iavl_prefix / ensure_inner_
prefix / ensure_leaf_prefix (zigzag-varint height/size/version + remaining-byte
count). native_decide witnesses then show the check accepts BOTH readings of the
same node preimage hsv || 0x20 || A || 0x20 || B — left leaves remaining=1, right
leaves remaining=34, and ensure_inner_prefix admits both.
Conclusion: re-including the IAVL prefix checks does not resolve F3; the
positional ambiguity is intrinsic to the prefix/suffix construction for every
shipped spec. General binding rests on preimage resistance, so closing
existence_binding needs an opaque/injective ('symbolic Merkle') hash model or a
weaker 'collision OR preimage' conclusion — a foundational reformulation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tement The original existence_binding concluded a hash collision outright. Finding F3 (machine-checked, incl. IavlPrefix.lean) shows that is too strong byte-level: the differing-path case can be a positional ambiguity rather than a collision. So the conclusion is corrected to the honest, true statement: HashCollision H OR PositionalAmbiguity s where PositionalAmbiguity formalizes F3 (two distinct ensureInner-accepted ops decomposing one node image). The same-shape theorem still yields a collision outright. Adds proven scaffolding toward the assembly: IsInnerImage and applyPath_result_isInnerImage (a non-empty path fold yields an inner-node image, the component that discharges the length-mismatch case via leaf/inner domain separation). The assembly induction remains the sorry; the symbolic-Merkle model (or this disjunction) is the documented path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hening) existence_binding_eqlen fully proves (no sorry) binding for two existence proofs of the same key, same leaf op, and equal path LENGTH but arbitrary differing inner ops: they yield HashCollision OR PositionalAmbiguity (F3). This strictly strengthens same-shape binding (which required identical ops) and covers all equal-depth proofs. Supporting, all proved: applyPath_eqlen_merge (the equal-length merge induction), applyInner_image, ensureInner_hash, verifyExistence_inners, plus a LawfulBEq HashOp instance. The general (arbitrary-length) existence_binding remains sorry — only the length-mismatch leaf/inner domain-separation case is left; applyPath_result_isInnerImage is its proven component. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ligations Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
leaf_inner_domain_collision: a leaf preimage (starting with the leaf prefix byte) and an inner preimage (not) that hash-collide under the same op yield an explicit HashCollision. This is the key reusable component for the length-mismatch case of general existence_binding (short proof's leaf hash meets long proof's intermediate node hash). With applyPath_result_isInnerImage and applyPath_eqlen_merge, all components of the general binding proof are now proved; only the root-side inductive assembly (aligning two differing-length folds from the root) remains as the sorry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ral core) applyPath_snoc: root-side characterization of a path fold (peel the last op). applyPath_merge: the structural core of general existence binding — two spec-conformant paths folding h1,h2 to one root yield collision, F3 ambiguity, h1=h2, or one input is an inner-node image (length-mismatch). Proved by root-side strong induction on total path length; because root-side peeling keeps the leaf inputs fixed, the conclusion is directly about them. Uses applyPath_snoc, applyInner_image, ensureInner_hash, innerImage_inj, applyPath_result_isInnerImage. This reduces general existence_binding to leaf-level case analysis (leaf injectivity for h1=h2; leaf/inner domain separation for the inner-image cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hasPrefix_single_head / not_hasPrefix_single_head: a single-byte prefix [b] determines (or excludes) the head byte of data. These let the leaf/inner domain-separation collision fire at the leaf level, the last component for wiring applyPath_merge into general existence binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
existence_binding_sameleaf (no sorry): two existence proofs sharing the same
leaf op but with ARBITRARY differing-length paths, binding one key to two values
under one root, yield HashCollision OR PositionalAmbiguity. This is the strongest
binding result, built on the root-side structural core applyPath_merge +
applyPath_snoc and leaf/inner domain separation (leafHash_innerImage_collision,
hasPrefix head lemmas), with leaf_value_collision and verifyExistence_leaf
helpers.
Instantiated for all three shipped specs with side conditions closed by decide:
existence_binding_{iavl,tendermint,smt}.
The only remaining existence sorry is the fully-general case (different leaf ops
-> leaf-level prefix ambiguity); for a fixed key in a real tree the leaf op is
determined, so the same-leaf theorem covers the honest case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decompress_index_no_panic (5/5 Kani harnesses now verified): decompress_exist's lookup.get(x as usize) is panic-free for any attacker-controlled i32 path index — a negative/out-of-range index wraps under 'as usize' and get returns None (mapped to an empty path), so the compressed-batch decode cannot panic on the index. Closes the compressed-batch item of Phase 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
existence_binding_shaped is fully proved (no sorry): for the production-spec shape (single-byte leaf prefix, shared leaf/inner hash op, min_prefix_length>=1), two existence proofs binding one key to two values under one root -- with NO assumption on their leaf ops or paths -- yield the honest three-way disjunction HashCollision OR PositionalAmbiguity OR LeafAmbiguity. The two ambiguity arms (F3 and its leaf-level analogue, LeafAmbiguity) are real, machine-checkable obstructions the abstract-hash model cannot rule out; this is the honest complete existence theorem. Built on the root-side structural core applyPath_merge + ensureLeaf_eq (leaves conforming with equal prefix are equal) + leafHash_innerImage_collision. Adds LawfulBEq LengthOp. The Soundness.existence_binding sorry is removed. sorry count 2 -> 1: only nonexistence_sound (Theorem B) remains, needing the symbolic-Merkle model. CI sorry guard updated to 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
applyPath_merge on (existence proof, neighbor proof) is vacuous: two different- key proofs legitimately diverge, which the model reads as PositionalAmbiguity (meaningful only for same-key existence binding). Non-existence soundness lives entirely in the key ordering + neighbor checks, requiring the ordered-tree position model. Captured as a finding for the Theorem B effort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tree.lean: inductive binary MTree with explicit structural bytes, rootHash (mirroring the verifier's leaf/inner hashing), TreeMember, and the left/right child-op reconstruction lemmas (a node's hash is reproduced by applying the left-child op to the left subtree hash, or the right-child op to the right). States membership_sound (Theorem A, honest-root form): an existence proof verifying against root = rootHash t implies genuine membership in t, up to a collision. Against a real root the F3 readings are genuine left/right children, so the inner positional ambiguity is resolved by tree structure. Proof to follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
split_pins is the crux of Option 1: for a binary node pre++lh++rh with |lh|=|rh|=cs, the verifier's prefix-length bound and suffix.length % cs = 0 check pin any accepted cs-length child split to EXACTLY the two genuine children (left or right subtree hash) — there is no straddling third reading. This is the structural fact that resolves the F3 positional ambiguity against a real root, proved by length arithmetic + List.append_inj. membership_sound's induction will apply this at each node. The crux is done. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WFTree: Tendermint-shaped conformance (node = H ih (pre++lh++rh), inner-spec hash, nonempty non-leaf-prefixed node prefix, spec leaf op). FixedHash: cs-byte digests (SHA-256 = 32 = child_size). applyLeaf_len / applyInner_len: outputs are cs-length — needed to feed split_pins' |child| = cs at each node. membership_sound now stated with the proper honest-root hypotheses; its induction (applying split_pins per node) is the remaining piece. The conceptual crux (F3 resolution, split_pins) is already proved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Builds the membership-soundness induction (reaches) for honest roots: - applyLeaf_head, ensureLeaf_self helpers; rootHash_len / applyPath_len. - reaches LEAF case fully proved: empty path -> leaf-leaf comparison via the joint-leaf-injectivity hypothesis (genuine membership or collision); nonempty path -> root is an inner image but also a leaf hash -> domain collision. - reaches NODE case: applies split_pins (the proven F3-resolution crux) to force the proof into a genuine left/right child, then recurses. The split_pins assembly is the remaining sorry. - membership_sound wraps reaches (sorry pending reaches). sorry count 1 -> 3 temporarily (the two Option-1 goals + Theorem B); CI guard updated. The conceptual crux (split_pins) and leaf case are proved; the node case is mechanical assembly. This closes the F3 ambiguity against real roots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
membership_sound is fully proved: an existence proof verifying against root = rootHash t (a real Tendermint-shaped tree) implies genuine membership in t, up to a hash collision — with NO ambiguity arm. The F3 positional ambiguity is fully resolved by split_pins: against a real root, both 'readings' of a node are the genuine left/right children, so the proof follows actual tree structure. The core induction (reaches) applies split_pins at each node, recursing into the real child; the leaf base uses joint-leaf-injectivity (hypothesis) and leaf/inner domain separation. split_bounds extracts split_pins' bounds from ensure_inner's i32 arithmetic; rootHash_len/applyPath_len supply the cs-length facts. This is Option 1 delivered for existence: a strictly stronger, honest result than the byte-level disjunction. sorry count back to 1 (only nonexistence_sound). Next: derive Theorem B from membership_sound (sortedness holds by construction in the tree model). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
membership_sound_tendermint instantiates the honest-root membership theorem for the Tendermint spec, discharging all structural side conditions by decide and leaving only FixedHash (32-byte digests) and joint leaf injectivity as the clean cryptographic assumptions. Documents the Option-1 honest-root Theorem A win. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The joint-leaf-injectivity hypothesis must require both applyLeaf results to be some r: the unconditional form is false when keys are empty (both sides none, but keys/values needn't match). Fixed in reaches, membership_sound, and the Tendermint corollary. membership_sound remains fully proved; sorry count 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TreeNonExist.lean: minKey/maxKey/SortedTree (key-sorted BST predicate) and the BST bounding lemmas — minKey_le_maxKey, member_le_maxKey, minKey_le_member (a member's key lies within [minKey t, maxKey t]). These are the ordered-tree reasoning that, with membership_sound, will close non-existence soundness: no member sits strictly between two adjacent leaves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
root_gap_no_member: in a sorted node, no member sits strictly between the left subtree's max and the right subtree's min — the core ordered-tree fact for non-existence. Built on the bounding lemmas + bytesLt transitivity/irreflexivity. Insight: ensure_right_most forces each step's suffix length to 0, which is exactly split_pins' 'right child' case — so the byte-level neighbor checks connect to the tree structure through split_pins (the same crux that resolved F3 for existence). The remaining work is the reaches-style lemma 'all-right path reaches maxKey'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in leaf) The byte->tree connection for Theorem B's neighbor structure: - split_right / split_left: empty suffix forces the right child, full cs-suffix forces the left child (specializing split_pins). - reaches_max: an all-right path (ensure_right_most) reaches maxKey t. - reaches_min: an all-left path (ensure_left_most) reaches minKey t. - ensureInner_minle: ensure_inner's lower prefix bound. These mirror the reaches induction but force a single direction via split_right/ left. With membership_sound + root_gap_no_member, the remaining work is the ensure_left_neighbor decomposition (navigate to the divergence node, then the all-right/all-left tails) and the general-node reduction to the root gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The BST side of Theorem B is now complete: - IsSubtree, subtree_sorted, subtree_minKey_le/maxKey_ge. - member_in_subtree_range: subtree contiguity — a member whose key is in a subtree's range is in that subtree (sorted trees occupy contiguous ranges). - ble_trans/ble_not_gt: <= order helpers. - node_gap_no_member: for ANY subtree N of a sorted tree, no member sits strictly between maxKey(N.left) and minKey(N.right). Generalizes root_gap via contiguity. With membership_sound + reaches_max/reaches_min, the only remaining piece is the ensure_left_neighbor decomposition connecting the byte paths to the divergence subtree N (l = maxKey(N.left), r = minKey(N.right)). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extracts the structured content of a passing ensure_left_neighbor check: the first divergent ops (topLeft/topRight) after the shared root path form a left-step, the left remainder is right-most, the right remainder is left-most. This is the byte-level entry point for connecting the neighbor walk to the divergence subtree in the honest-root tree model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ensureRightMost_suffix_nil / ensureLeftMost_suffix_cs: for a binary spec whose emptyChild sentinel is not a full cs-byte digest (the Tendermint shape, emptyChild = []), every step of an ensure_right_most / ensure_left_most path is a genuine pad (empty / full-cs suffix), never an empty-branch placeholder. The placeholder branch would require emptyChild.length = cs, excluded by hypothesis. This resolves the subtlety where a placeholder 'right-most' step (suffix length cs) would split-pin LEFT rather than right — pinning the precise precondition under which reaches_max/reaches_min apply to the neighbor remainders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The combined induction connecting the ensure_left_neighbor byte structure to the honest tree. Two verifying existence proofs whose reversed paths share a root-side prefix and then diverge as a left-step navigate to a common node N (a subtree): the left proof reaches maxKey(N.left), the right reaches minKey(N.right), up to a collision. - Common root step (eqPS top ops): append-cancellation gives mL = mR; both proofs descend into the same child; recurse, lifting the subtree witness via IsSubtree. - Divergence (isLeftStep): this node is N; split_left/split_right pin the two proofs into l and r; the right-most/left-most remainders feed reaches_max / reaches_min (suffix shapes supplied by the F4/F5 padding bridges). - Empty paths / leaf trees collapse to leaf-inner domain or inner-image collisions. With node_gap_no_member this is everything needed to assemble nonexistence_sound in the honest-root model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t model nonexistence_sound_tree (+ _tendermint wrapper): the honest-root analog of membership_sound, fully proved with no sorry. For a key-sorted honest tree, a two-sided non-existence proof and an existence proof for the same key cannot both verify without a hash collision. The assembly: - verifyExistence_navigates: a verifying existence proof folds its leaf hash along its path to the root (extracted from membership_sound's body). - membership_sound makes the absent key a genuine tree member (or collision). - ensureLeftNeighbor_spec + neighbor_divergence pin the bracketing proofs to a divergence node N: left reaches maxKey(N.left), right reaches minKey(N.right). - node_gap_no_member: in a sorted tree no member sits in that gap; the absent key, were it a member, would — contradiction, forcing a collision. The abstract-root nonexistence_sound stays the one acknowledged sorry by design (F3/F4: an opaque root has no tree structure linking byte-order to position), exactly as Theorem A's real result is honest-root membership_sound. Docs and CI sorry-guard updated to reflect that both theorems are now proved in the tree model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds do_length_fixed_no_panic: the data.len() casts and to_be/le_bytes extends for NoPrefix and the Fixed32/64 ops cannot panic. VarProto/Require* are excluded (anyhow construction blows up CBMC's formula). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ding leafInj_varProto (LeafInj.lean): for any varProto-framed leaf op, two leaves hashing to the same value share their (key, value) or exhibit a hash collision — no axioms. Reduction is varint-self-delimiting + collision resistance: equal images give either a collision on the leaf hash op (differing preimages) or equal preimages, which cancel the fixed prefix; varintEncode_append_inj + append_inj then peel the two length-framed pieces, leaving H_phk k1 = H_phk k2 and H_phv v1 = H_phv v2, each an equality or a collision on the prehash op. Instantiated axiom-free as leafInj_tendermint and leafInj_iavl (identical leaf ops). The Tendermint wrappers membership_sound_tendermint and nonexistence_sound_tree_tendermint no longer take hLInj as a hypothesis — only FixedHash remains as a cryptographic assumption. This closes the load-bearing residual assumption flagged in the threat-model analysis (the leaf-encoding ambiguity class where Dragonberry-style F1/F2 bugs live). SMT leaf injectivity (length := .noPrefix) needs the fixed-32-byte-hash argument instead and is left as follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
leafInj_noPrefix (LeafInj.lean): for a noPrefix leaf op over a fixed-length hash (FixedHash H cs), two leaves hashing to the same value share their (key, value) or exhibit a collision. The SMT shape is H_hash(prefix ++ H_phk k ++ H_phv v) with no varint frame; both prehash digests are cs bytes wide, so the concatenation splits at the fixed boundary via List.append_inj (the analogue of varintEncode_append_inj). Instantiated as leafInj_smt (FixedHash H 32), axiom-free beyond the fixed-width hash assumption. All three shipped specs now have joint leaf injectivity proved from the encoding: leafInj_tendermint / leafInj_iavl (varProto) and leafInj_smt (noPrefix + fixed length). FixedHash moved from Tree.lean to Soundness.lean so LeafInj can use it without an import cycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend Theorem B to every proof shape verify_non_existence accepts:
- nonexistence_sound_tree_leftOnly: a left-only proof's ensure_right_most
path makes its neighbor the rightmost leaf (reaches_max), so
maxKey t < key contradicts member_le_maxKey for any verifying
existence proof.
- nonexistence_sound_tree_rightOnly: mirror via reaches_min /
minKey_le_member.
- nonexistence_sound_tree_total: case split over the four neighbor
shapes (verifyNonExistence_none shows no-neighbor proofs never
verify), subsuming the two-sided theorem.
- nonexistence_sound_tree_tendermint_total: Tendermint instantiation,
side conditions by decide, FixedHash the only assumption.
Extraction lemmas verifyNonExistence_{leftOnly,rightOnly,none} added to
NonExistSound.lean. Dropped the vestigial hkey hypothesis from the
two-sided tree theorems (membership_sound derives it internally).
Updated properties.md, which still described Theorem B as unproved.
Sorry count unchanged (1, the deliberate abstract-root statement).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MTree model cannot represent sparse-merkle trees: SMT nodes may have empty subtrees whose digest is the constant empty_child placeholder (32 zero bytes for smt_spec, the same length as a real digest). Add SMTree with an .empty constructor, rootHashS mapping it to the placeholder, WFTreeS, and the one new explicitly-stated assumption EmptyChildFree (no exhibited hash preimage of the placeholder, the standard sparse-merkle assumption). Theorem A transfers: reachesS reuses split_pins byte-level and rules out folds terminating in an empty subtree via applyPath_ne_emptyChild (a leaf-rooted spec-conformant fold is always a hash image, never the placeholder). membership_sound_smt instantiates it for smt_spec with leafInj_smt; remaining hypotheses are FixedHash and EmptyChildFree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…total) Non-existence soundness in the honest-root SMT tree model, covering all verifier-accepted shapes (two-sided, left-only, right-only). The F4/F5 padding bridge that excluded the placeholder arm for Tendermint (emptyChild.length != childSize) is replaced by bridges that embrace it: an ensure_right_most step either has an empty suffix (genuine right step) or a suffix exactly equal to emptyChild, which under EmptyChildFree pins the skipped right sibling to a genuinely empty subtree (rootHashS_ec_empty); mirrored for ensure_left_most via the prefix's trailing 32 bytes. reaches_maxS / reaches_minS thus land on the rightmost / leftmost *nonempty* leaf (Option-valued maxKeyS / minKeyS), and neighbor_divergence_smt pins a two-sided proof to a divergence node as before. Key order throughout is on comparison keys (keyForComparison, i.e. SHA-256-prehashed keys for smt_spec), with a members-based SortedTreeS formulation that makes the BST gap argument compositional over empty subtrees. nonexistence_sound_smt_total discharges every structural side condition by computation; the remaining hypotheses are FixedHash, EmptyChildFree, and sortedness of the honest tree on prehashed keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
properties.md: new "Remaining obligations" item 2a for the SMT model and theorems; sections A and B now point at the proved theorems instead of "proof in progress" / "not yet". lean/README.md: the whitelisted sorry is the abstract-root nonexistence_sound (not existence_binding, long since proved); complete the file table; add the honest-root Theorem A/B and SMT entries to the status list; update next steps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Tendermint-shape membership_sound assumes min_prefix_length = max_prefix_length, which IAVL violates (4-12 bytes of height/size/ version varints) and relies on digest length = child_size, which IAVL also violates (33-byte amino child slots = 0x20 length byte + 32-byte digest). Model an IAVL node as the existing MTree shape with a 1-byte mid (the right child's 0x20 length byte) and a [min,max]-windowed prefix (WFTreeI). The pinning argument is replaced: split_pins_var shows the verifier's suffix % child_size = 0 check together with max_prefix_length < child_size pins the suffix length to 0 or child_size, and each choice resolves the entire split to a genuine child via plain append injectivity - no prefix-length pinning needed, so variable prefixes are fine. The max < min + child_size well-formedness family is what makes this work. membership_sound_iavl discharges every structural side condition by computation; with leafInj_iavl already proved, FixedHash is the only remaining assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tal) Non-existence soundness in the honest-root IAVL tree model, covering all verifier-accepted shapes (two-sided, left-only, right-only). With this, honest-root Theorems A and B hold for all three shipped specs. The order-theoretic machinery reuses TreeNonExist.lean verbatim: maxKey/minKey/SortedTree, node_gap_no_member, the neighbor-walk decomposition, and - because IAVL's emptyChild is [] (length 0 != 33) - the Tendermint-style padding bridges instantiated at cs = 33. The navigation lemmas (reaches_maxI, reaches_minI, neighbor_divergenceI) are rebuilt on split_var_left/right/split_pins_var, which pin a node split from the suffix length alone, accommodating IAVL's variable 4-12 byte prefixes and 33-byte child slots. nonexistence_sound_iavl_total discharges every structural side condition by computation; with leafInj_iavl already proved, FixedHash is the only remaining assumption (key order is raw bytesLt since prehash_key_before_comparison = false). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sha256.pad computed the zero-padding count with truncating Nat subtraction: (56 - (len+1) % 64 + 64) % 64 clamps to (0 + 64) % 64 = 0 whenever (len+1) % 64 > 56, so the padded message was not a multiple of 64 bytes and the block loop silently dropped the trailing bytes. Every message with length = 56..62 (mod 64) hashed incorrectly. The three embedded validation vectors (0, 4, 6 bytes) and every Tendermint/SMT test preimage happen to miss that window; the IAVL differential vectors (57-byte leaf preimages) land exactly in it, which is how the Phase 2a oracle caught this on its first run. Replace with (120 - (len+1) % 64) % 64, which never truncates for remainders in [0, 63], and add padding-boundary regression vectors (55, 56, 57, 62, 63, 64 bytes) cross-checked against hashlib. Scope: Sha256.lean is the executable layer only; the soundness theorems are abstract over HashFn and were never affected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rust/examples/lean_testdata.rs decodes every testdata/{iavl,tendermint,
smt} vector's protobuf CommitmentProof (18 vectors, using only existing
dev-dependencies) and generates lean/Ics23/TestVectors.lean: the
decoded proofs as Lean literals plus a native_decide acceptance check
per vector mirroring the Rust verify_membership/verify_non_membership
call. lake build now re-verifies every shared vector against the Lean
model with real SHA-256, so an accept/reject divergence between the
model and the implementations fails the proof build.
CI regenerates the file and fails if it drifts from the committed copy
(lean.yml now also triggers on testdata/ and the generator).
The oracle paid for itself immediately: on its first run it rejected
all six IAVL vectors, exposing the Sha256.pad truncating-subtraction
bug fixed in the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All eight findings from the July 2024 Zellic assessment recorded in
Corpus.lean under a new "Zellic audit findings" section, with
machine-checked model witnesses where the model has a decidable
counterpart:
- 3.1 (sorted-keys nonexistence soundness, Critical): reconstruct the
Zellic Fig. 3.1 tree as an MTree; machine-check that "b" is a genuine
member yet the tree is NOT SortedTree -- i.e. the exact SortedTree
hypothesis Theorem B requires (modeling note F4).
- 3.2 (MaxPrefixLength >= MinPrefixLength + ChildSize, Critical): the
boundary spec fails wellFormedB and the overlapping op fails
ensureInner; this is the max < min + child_size bound the
split-pinning lemmas rely on.
- 3.4 (zero child_size division, High): ensureInner rejects against a
zero-child_size spec; model is %-total (no panic).
- 3.5 (unrestricted proof size, Medium): over-deep path fails
checkExistenceSpec once the depth gate is armed; shipped specs
disable it (min_depth = 0), faithfully reflecting the finding.
- 3.6 (out-of-bounds ChildOrder panic, Low): getPadding is total --
getPadding {childOrder := [0,2]} 1 = none, no panic.
Implementation-only / out-of-scope findings recorded as notes: 3.3
(TypeScript, removed upstream), 3.7 (compressed batch proofs, out of
model scope), 3.8 (stricter IAVL/Tendermint checks -- baked into the
honest-root WFTree predicates).
properties.md: "Sources to mine" updated, a Zellic finding map table
added, and the pending obligations marked done.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrap two multi-argument assert! calls across lines per rustfmt. No logic change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR began as an RFC proposing a formal verification program for the ICS23 verifier and now carries the verification itself: a mechanized Lean 4 model of the verifier with machine-checked soundness theorems, Kani panic/overflow harnesses, an executable model with concrete SHA-256, and a set of findings surfaced by the work.
The verifier is the security boundary of IBC state verification: every byte of a
CommitmentProofis attacker-controlled, and a soundness bug means forgeable state proofs across IBC. The hand-written verification core is small, purely functional, and has a crisp cryptographic correctness statement — an unusually good target for formal methods.What is proved (Lean 4, no Mathlib, ~3,800 lines)
All soundness results conclude by constructively exhibiting a hash collision — there are no axioms about the hash functions. The only cryptographic assumption that remains is
FixedHash(a fixed-width digest), and even joint leaf injectivity is now a theorem rather than an assumption.existence_binding_shaped, production-spec shape) and, in the honest-root tree model, as genuine membership in the real tree with no positional-ambiguity arm (membership_sound/membership_sound_tendermint).nonexistence_sound_tree/nonexistence_sound_tree_tendermint). The chain:membership_soundmakes the absent key a genuine member;ensureLeftNeighbor_spec+neighbor_divergencepin the bracketing proofs to a divergence nodeN;node_gap_no_membershows no member can sit betweenmaxKey N.leftandminKey N.rightin a sorted tree.hLInj), now proved for every shipped spec:leafInj_varProto(IAVL/Tendermint, via varint self-delimiting) andleafInj_noPrefix(SMT, via the fixed-32-byte digest split). The reduction is exactly "encoding framing + collision resistance."cfg(kani)-gated) over the arithmetic and slice-indexing in the verifier internals — the Dragonberry-class surface — including the i32 prefix bounds, padding offsets, left/right empty-branch slice bounds, and compressed-batch index handling.Sha256.lean,Executable.lean) and a regression corpus, with the non-existence verifier exercised end-to-end on a real tree.One acknowledged
sorryremains, by design: the abstract-rootnonexistence_sound. It is not provable as stated — an opaque root carries no tree structure linking byte-order to position (findings F3/F4) — which is exactly why the genuine result is the honest-rootnonexistence_sound_tree, mirroring how Theorem A's real deliverable is honest-rootmembership_sound. CI guards the sorry count at exactly one.Findings surfaced by the work
ensure_innerunder an adversarial spec with an enormouschild_size. Not reachable with the shipped specs; a Kani harness pins the safe precondition.child_sizesuffix but indexes multiple siblings; latent for any spec withchild_order.len() > 2. No shipped store is ternary.ensure_innerunder both a left-child and a right-child reading. Binding against an arbitrary hash therefore rests on preimage resistance, not just collision resistance. Machine-checked that the IAVLensure_inner_prefix(height/size/version) does not disambiguate it. The honest-root tree model resolves this structurally viasplit_pins: the verifier'ssuffix % child_sizecheck pins a node split to exactly the two genuine children.SortedTreehypothesis.Repo layout
docs/rfc/001-formal-verification.md— the RFC.docs/verification/properties.md— properties, findings, and a traceability matrix.lean/— the Lean 4 Lake project;lake buildchecks every proof, and CI runs it.rust/src/kani_proofs.rs— the Kani harnesses.Approach and alternatives
Model-first: a mechanized Lean model of the verifier with machine-checked soundness theorems, intended to connect to both shipped implementations via a differential oracle, with Kani harnesses and fuzz targets on the Rust crate, and an optional refinement proof of
ics23-rs(hax / coq-of-rust) as a stretch goal. Alternatives considered (Verus/Creusot, TLA+/Quint, F* end-to-end, more auditing) are discussed and rejected with reasoning in the RFC.Discussion wanted
The RFC ends with open questions for maintainers — Lean 4 vs. Rocq, whether the refinement proof is in scope, batch proof sequencing, and whether the model should live in this repo or a sibling.
🤖 Generated with Claude Code