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}