Skip to main content

spine/
leaf_proof.rs

1//! Leaf proof — a first-class, live "is this a legitimate leaf?" witness.
2//!
3//! The leaf proof is the **peer of the inclusion proof** ([`crate::proof`]) over
4//! the *same* shared positional topology ([`crate::topology`]): where a raw
5//! [`verify_inclusion`](crate::proof::verify_inclusion) call binds a leaf to a
6//! log position from seven loose parameters, a [`LeafProof`] packages the leaf
7//! hash and its trusted positional parameters into one self-contained value, so
8//! a consumer asks a single question — `verify(hasher, root)` — against an
9//! authenticated root.
10//!
11//! It runs over a **live** tree (both the append-only EML and the mutable EMT
12//! expose it), and is **not** consistency-coupled: a tree that proves no
13//! consistency still answers "legitimate leaf?". It is the base case the
14//! snapshot proof composes.
15//!
16//! # Trust contract (security-critical)
17//!
18//! A `LeafProof` carries `index`, `tree_size`, and `arity` — these are
19//! **trusted positional parameters**, inherited verbatim from the inclusion
20//! contract ([`verify_inclusion`](crate::proof::verify_inclusion)). They pin the
21//! topology the verifier reconstructs and MUST come from an authenticated source
22//! (a signed Tree Head or trusted checkpoint), never from caller-untrusted
23//! input. The `root` passed to [`LeafProof::verify`] must likewise be
24//! authenticated for that `tree_size`. A `true` result binds `leaf_hash` to log
25//! position `index` only — payload activity is read from the committed epoch
26//! timeline, never inferred here.
27
28use crate::hasher::Hasher;
29use crate::proof::{ProofStep, verify_inclusion};
30use crate::topology::SkeletonStep;
31
32/// A self-contained leaf proof: a leaf hash bound to its position, ready to
33/// verify against a trusted root and the structure's skeleton.
34///
35/// The path and positional fields are exactly the inclusion contract; bundling
36/// them keeps the proof self-describing about *which* position it claims. Because
37/// the structural core is topology-agnostic, `verify` takes the concrete
38/// `skeleton` for the leaf's position — the holding consumer computes it from the
39/// trusted `(index, tree_size, arity)` under its own topology (an append-only
40/// log's mountain skeleton, a mutable tree's rebalanced skeleton).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct LeafProof {
43    /// The proven leaf's digest at `index`.
44    pub leaf_hash: Vec<u8>,
45    /// Trusted log position of the leaf (0-indexed).
46    pub index: u64,
47    /// Trusted size of the tree the proof is rooted in.
48    pub tree_size: u64,
49    /// Trusted fixed arity of the proof spine (`2..=256`).
50    pub arity: u64,
51    /// Path steps from the leaf to the root, over the shared positional
52    /// topology — the same shape [`verify_inclusion`] reconstructs against.
53    pub path: Vec<ProofStep>,
54}
55
56impl LeafProof {
57    /// Assemble a leaf proof from a leaf hash and its trusted positional
58    /// parameters plus an inclusion path.
59    ///
60    /// This is the kernel-level producer the engineering libraries (EML, EMT)
61    /// wrap around their own path generation; they own the live-tree walk, the
62    /// kernel owns the self-contained witness shape.
63    #[must_use]
64    pub fn new(
65        leaf_hash: Vec<u8>,
66        index: u64,
67        tree_size: u64,
68        arity: u64,
69        path: Vec<ProofStep>,
70    ) -> Self {
71        Self {
72            leaf_hash,
73            index,
74            tree_size,
75            arity,
76            path,
77        }
78    }
79
80    /// Verify the leaf proof against an authenticated `root` and the structure's
81    /// `skeleton` for this leaf's position: is `leaf_hash` the legitimate leaf at
82    /// `index` in the size-`tree_size` tree rooted at `root`?
83    ///
84    /// Soundness rests on the verifier pinning the proof's trailing steps against
85    /// `skeleton`; the proof supplies only sibling digests. The `skeleton` is the
86    /// consumer's concrete topology for the trusted `(index, tree_size, arity)`
87    /// and MUST be authenticated alongside `root` (see the module-level trust
88    /// contract).
89    #[must_use]
90    pub fn verify(&self, hasher: &dyn Hasher, skeleton: &[SkeletonStep], root: &[u8]) -> bool {
91        verify_inclusion(hasher, &self.leaf_hash, skeleton, &self.path, root)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use sha2::{Digest, Sha256};
98
99    use super::*;
100    use crate::mr::within_subtree_path;
101    use crate::subtree::Subtree;
102
103    #[derive(Debug)]
104    struct Sha256Hasher;
105
106    impl Hasher for Sha256Hasher {
107        fn leaf(&self, data: &[u8]) -> Vec<u8> {
108            Sha256::digest(data).to_vec()
109        }
110
111        fn node(&self, children: &[&[u8]]) -> Vec<u8> {
112            let mut h = Sha256::new();
113            for child in children {
114                h.update(child);
115            }
116            h.finalize().to_vec()
117        }
118
119        fn empty(&self) -> Vec<u8> {
120            Sha256::digest(b"").to_vec()
121        }
122
123        fn hash(&self, data: &[u8]) -> Vec<u8> {
124            Sha256::digest(data).to_vec()
125        }
126
127        fn clone_box(&self) -> Box<dyn Hasher> {
128            Box::new(Sha256Hasher)
129        }
130    }
131
132    /// A balanced binary subtree over `n` leaves `0..n`, mirroring the canonical
133    /// k=2 frontier shape a flat log of that size produces. Used as an in-test
134    /// live tree so the kernel leaf proof can be exercised without an engineering
135    /// crate.
136    fn balanced(n: u64) -> Subtree {
137        fn build(lo: u64, hi: u64) -> Subtree {
138            if hi - lo == 1 {
139                return Subtree::Leaf(format!("leaf-{lo}").into_bytes());
140            }
141            // Largest power-of-two strictly below the span, matching the k=2
142            // frontier's leftmost-perfect split.
143            let span = hi - lo;
144            let mut left_span = 1;
145            while left_span * 2 < span {
146                left_span *= 2;
147            }
148            Subtree::Node(vec![build(lo, lo + left_span), build(lo + left_span, hi)])
149        }
150        build(0, n)
151    }
152
153    /// Produce a leaf proof for `index` over the balanced k=2 tree of size `n`,
154    /// using the kernel's own subtree-path generator.
155    fn proof_for(hasher: &dyn Hasher, n: u64, index: u64) -> (LeafProof, Vec<u8>) {
156        let tree = balanced(n);
157        let root = crate::mr::evaluate(hasher, &tree);
158        let path = within_subtree_path(hasher, &tree, index).expect("index in range");
159        let leaf_hash = hasher.leaf(format!("leaf-{index}").into_bytes().as_slice());
160        (LeafProof::new(leaf_hash, index, n, 2, path), root)
161    }
162
163    /// Spec: a legitimate leaf's proof verifies against the genuine root, at
164    /// every position of every tree size in the swept range.
165    #[test]
166    fn legitimate_leaf_is_accepted() {
167        let hasher = Sha256Hasher;
168        for n in 1u64..=33 {
169            let tree = balanced(n);
170            let root = crate::mr::evaluate(&hasher, &tree);
171            for index in 0..n {
172                let (proof, root2) = proof_for(&hasher, n, index);
173                assert_eq!(root2, root, "n={n} index={index}");
174                // `balanced` is one explicit subtree (no frontier grouping), so
175                // every path step is a hash-chained subtree-prefix step and the
176                // skeleton is empty.
177                assert!(proof.verify(&hasher, &[], &root), "n={n} index={index}");
178            }
179        }
180    }
181
182    /// Spec: a forged leaf (wrong payload at the proven position) is rejected.
183    #[test]
184    fn forged_leaf_is_rejected() {
185        let hasher = Sha256Hasher;
186        for n in 2u64..=33 {
187            for index in 0..n {
188                let (mut proof, root) = proof_for(&hasher, n, index);
189                proof.leaf_hash = hasher.leaf(b"forged-payload");
190                assert!(!proof.verify(&hasher, &[], &root), "n={n} index={index}");
191            }
192        }
193    }
194
195    /// Property: a proof for one position never verifies a leaf hash bound to a
196    /// *different* position's payload (the path pins the position).
197    #[test]
198    fn proof_does_not_transfer_across_positions() {
199        let hasher = Sha256Hasher;
200        let n = 16;
201        for index in 0..n {
202            let (mut proof, root) = proof_for(&hasher, n, index);
203            for other in 0..n {
204                if other == index {
205                    continue;
206                }
207                proof.leaf_hash = hasher.leaf(format!("leaf-{other}").into_bytes().as_slice());
208                assert!(
209                    !proof.verify(&hasher, &[], &root),
210                    "index={index} other={other}"
211                );
212            }
213            // Restore: the genuine leaf still verifies.
214            proof.leaf_hash = hasher.leaf(format!("leaf-{index}").into_bytes().as_slice());
215            assert!(proof.verify(&hasher, &[], &root), "index={index}");
216        }
217    }
218
219    /// Property: a proof never verifies against the root of a genuinely
220    /// different tree (wrong-root rejection).
221    ///
222    /// Topology/size binding is now a property of the supplied skeleton, not of
223    /// `LeafProof::verify` (the structural core no longer derives topology from
224    /// `tree_size`); the mismatched-size rejection is exercised where the
225    /// skeleton is computed — the `cml`/`cmt` topology tests.
226    #[test]
227    fn wrong_root_is_rejected() {
228        let hasher = Sha256Hasher;
229        let (proof, root) = proof_for(&hasher, 8, 3);
230        // A different tree (different size) has a different root.
231        let other_root = crate::mr::evaluate(&hasher, &balanced(12));
232        assert_ne!(root, other_root);
233        assert!(proof.verify(&hasher, &[], &root));
234        assert!(!proof.verify(&hasher, &[], &other_root));
235    }
236
237    /// The leaf proof is exactly the inclusion contract repackaged: its `verify`
238    /// agrees with a direct `verify_inclusion` call on the same skeleton.
239    #[test]
240    fn verify_agrees_with_raw_inclusion() {
241        let hasher = Sha256Hasher;
242        let (proof, root) = proof_for(&hasher, 11, 7);
243        let direct = verify_inclusion(&hasher, &proof.leaf_hash, &[], &proof.path, &root);
244        assert_eq!(proof.verify(&hasher, &[], &root), direct);
245        assert!(direct);
246    }
247}