spine/proof.rs
1//! Inclusion proof structures and the **abstract** verifier.
2//!
3//! # Security boundary: skeleton-pinned, prefix-chained
4//!
5//! An inclusion proof path runs leaf → root and splits into two regions:
6//!
7//! - The **structural skeleton** — the trailing steps along the structure's commitment topology.
8//! Their shape (count, per-step position and sibling count) is a [`SkeletonStep`] sequence the
9//! *consumer* computes from its own trusted `(index, tree_size, arity)` and passes in; the
10//! verifier pins the proof's trailing steps against it exactly. Because there is no per-node
11//! domain separation, second-preimage safety rests entirely on this exactness: the verifier
12//! rejects any deviation from the supplied skeleton.
13//! - The **subtree prefix** — the leading steps below the leaf's structural position, in
14//! application-defined (non-uniform) subtrees. These carry no topological claim and are verified
15//! by hash chaining alone.
16//!
17//! The verifier is **topology-agnostic**: it knows the skeleton *mechanism* (pin
18//! the trailing steps, hash-chain the prefix) but not the concrete topology. The
19//! append-only log supplies a mountain-range skeleton, the mutable tree a
20//! rebalanced one; one verifier serves both because the skeleton is the seam.
21//! This mirrors the Lean corpus, whose `inclusion_soundness` is stated over an
22//! abstract `SkeletonValid` predicate rather than a baked-in topology.
23//!
24//! ## Canonical proof encoding
25//!
26//! Every accepted step hashes: it must carry at least one sibling. A zero-sibling
27//! step would represent a *promoted* (lone-child) node, whose parent equals its
28//! child without any hashing — an inert no-op. Such steps are therefore rejected
29//! everywhere ([`reconstruct_inclusion_root`]), and honest provers omit them
30//! ([`crate::within_subtree_path`]). Omitting a promoted step never changes the
31//! computed root, so completeness is preserved; in exchange, a fixed
32//! `(leaf_hash, skeleton, root)` admits at most one accepting path
33//! (modulo hash collisions), which closes prepend/insert malleability. This
34//! concerns zero-*sibling* steps only; null-*valued* siblings from a null
35//! collapse are unaffected.
36
37use crate::hasher::Hasher;
38use crate::mr::nary_mr;
39use crate::topology::SkeletonStep;
40
41/// A single level in a Merkle proof path.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ProofStep {
44 /// Sibling digests at this level (excluding the path node).
45 /// Empty for promoted (lone-child) nodes.
46 pub siblings: Vec<Vec<u8>>,
47 /// Position of the path node among all children (0-indexed).
48 pub position: usize,
49}
50
51impl ProofStep {
52 /// Project this step's structural shape — position and sibling count —
53 /// as a [`crate::topology::SkeletonStep`]. Used by
54 /// [`verify_inclusion_path_structure`] to compare against the canonical
55 /// skeleton without open-coding the field correspondence.
56 #[must_use]
57 pub fn shape(&self) -> crate::topology::SkeletonStep {
58 crate::topology::SkeletonStep {
59 position: self.position,
60 sibling_count: self.siblings.len(),
61 }
62 }
63}
64
65/// Inclusion proof: path from a leaf to the root.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct InclusionProof {
68 /// Path steps from leaf to root.
69 pub path: Vec<ProofStep>,
70}
71
72/// Timing-safe comparison of two byte slices.
73#[inline]
74pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
75 if a.len() != b.len() {
76 return false;
77 }
78 let mut result = 0;
79 for (&x, &y) in a.iter().zip(b.iter()) {
80 result |= std::hint::black_box(x) ^ std::hint::black_box(y);
81 }
82 std::hint::black_box(result) == 0
83}
84
85/// Verify an inclusion proof against a consumer-supplied skeleton.
86///
87/// Returns `true` if `path` demonstrates that `leaf_hash` reaches `root` along a
88/// path whose trailing steps match `skeleton` exactly.
89///
90/// # Trust contract (security-critical)
91///
92/// `skeleton` and `root` are **trusted parameters**. The skeleton is the
93/// structure's canonical topology, computed by the consumer from its own trusted
94/// `(index, tree_size, arity)` (an append-only log's mountain skeleton, a mutable
95/// tree's rebalanced skeleton). Soundness comes from the verifier pinning the
96/// proof's trailing steps against this exact skeleton and rejecting any
97/// deviation; the proof itself supplies only sibling digests. The skeleton and
98/// root MUST therefore be obtained from an authenticated source — derived from a
99/// signed Tree Head (STH) or trusted checkpoint — never from the proof or any
100/// caller-untrusted input. If the position/size the skeleton encodes are
101/// attacker-controlled the guarantee is vacuous: the attacker picks the topology
102/// the verifier checks against, and an arbitrary `leaf_hash` can be made to
103/// "verify" against a matching forged `root`.
104///
105/// A `true` result binds `leaf_hash` to the position the skeleton pins only. The
106/// cell's payload and activity are not asserted here — activity is read from the
107/// committed epoch timeline (an `epoch` concept), never inferred from a digest.
108#[must_use]
109pub fn verify_inclusion(
110 hasher: &dyn Hasher,
111 leaf_hash: &[u8],
112 skeleton: &[SkeletonStep],
113 path: &[ProofStep],
114 root: &[u8],
115) -> bool {
116 reconstruct_inclusion_root(hasher, leaf_hash, skeleton, path)
117 .is_some_and(|computed| constant_time_eq(&computed, root))
118}
119
120/// Validate that the trailing steps of an inclusion proof path match the
121/// consumer-supplied `skeleton`.
122///
123/// The skeleton — its length and, per step, the path node's position and sibling
124/// count — is the structure's canonical topology, computed once by the consumer
125/// (the single authority on its own topology, shared with proof generation). The
126/// trailing `skeleton.len()` steps are checked field-by-field against it; the
127/// leading `path.len() - skeleton.len()` steps are the subtree portion and carry
128/// no topological claim here (they are verified by hash chaining in
129/// [`reconstruct_inclusion_root`]).
130#[must_use]
131pub fn verify_inclusion_path_structure(skeleton: &[SkeletonStep], path: &[ProofStep]) -> bool {
132 if path.len() < skeleton.len() {
133 return false;
134 }
135 let d = path.len() - skeleton.len();
136 path[d..]
137 .iter()
138 .zip(skeleton.iter())
139 .all(|(step, shape)| step.shape() == *shape)
140}
141
142/// Reconstruct the raw root from an inclusion proof path and its trusted
143/// skeleton.
144///
145/// Building block for [`verify_inclusion`]; it computes a root but does not
146/// compare it to a trusted one. Callers must hold to the same trust contract:
147/// `skeleton` must be authenticated (see [`verify_inclusion`]), and the returned
148/// root is only meaningful when checked against an authenticated root. A
149/// well-formed skeleton implies the position/size were valid — the consumer that
150/// computed it rejects an out-of-range position by producing no skeleton — so the
151/// core needs no separate `(index, tree_size, arity)` bounds, only the
152/// digest-width and DoS bounds below.
153#[must_use]
154pub fn reconstruct_inclusion_root(
155 hasher: &dyn Hasher,
156 leaf_hash: &[u8],
157 skeleton: &[SkeletonStep],
158 path: &[ProofStep],
159) -> Option<Vec<u8>> {
160 let digest_len = hasher.empty().len();
161 if digest_len == 0 || digest_len > 64 {
162 return None;
163 }
164 if leaf_hash.len() != digest_len {
165 return None;
166 }
167 if path.len() > 256 {
168 return None;
169 }
170
171 if !verify_inclusion_path_structure(skeleton, path) {
172 return None;
173 }
174
175 let mut current = leaf_hash.to_vec();
176
177 for step in path {
178 if step.siblings.len() > 256 {
179 return None;
180 }
181 for sib in &step.siblings {
182 if sib.len() != digest_len {
183 return None;
184 }
185 }
186 if step.siblings.is_empty() {
187 // Canonical proof encoding: a zero-sibling step would be a promoted
188 // (lone-child) node, whose parent equals the child without hashing.
189 // Such steps are inert no-ops, so honest provers omit them; rejecting
190 // them here makes the accepting path unique for a fixed
191 // (leaf_hash, skeleton, root). See the module docs.
192 return None;
193 }
194 if step.position > step.siblings.len() {
195 return None;
196 }
197
198 // Reconstruct the parent: insert current at position among siblings
199 let mut children = Vec::with_capacity(step.siblings.len() + 1);
200 for (i, sib) in step.siblings.iter().enumerate() {
201 if i == step.position {
202 children.push(current.as_slice());
203 }
204 children.push(sib.as_slice());
205 }
206 if step.position == step.siblings.len() {
207 children.push(current.as_slice());
208 }
209
210 current = nary_mr(hasher, &children);
211 }
212
213 Some(current)
214}
215
216#[cfg(test)]
217mod tests {
218 use sha2::{Digest, Sha256};
219
220 use super::*;
221
222 #[derive(Debug)]
223 struct H;
224 impl Hasher for H {
225 fn leaf(&self, data: &[u8]) -> Vec<u8> {
226 Sha256::digest(data).to_vec()
227 }
228
229 fn node(&self, children: &[&[u8]]) -> Vec<u8> {
230 let mut h = Sha256::new();
231 for c in children {
232 h.update(c);
233 }
234 h.finalize().to_vec()
235 }
236
237 fn empty(&self) -> Vec<u8> {
238 Sha256::digest(b"").to_vec()
239 }
240
241 fn hash(&self, data: &[u8]) -> Vec<u8> {
242 Sha256::digest(data).to_vec()
243 }
244
245 fn clone_box(&self) -> Box<dyn Hasher> {
246 Box::new(H)
247 }
248 }
249
250 /// A zero-sibling (promoted) step is rejected: the accepting path is unique.
251 #[test]
252 fn zero_sibling_step_is_rejected() {
253 let h = H;
254 let leaf = h.leaf(b"x");
255 let path = vec![ProofStep {
256 siblings: vec![],
257 position: 0,
258 }];
259 // An empty skeleton treats the lone step as a subtree-prefix step; the
260 // hash-chaining loop still rejects it because it carries no sibling. A
261 // promoted step never reconstructs a root.
262 assert_eq!(reconstruct_inclusion_root(&h, &leaf, &[], &path), None);
263 }
264
265 /// `constant_time_eq` agrees with `==` on equal and unequal byte slices.
266 #[test]
267 fn constant_time_eq_matches_equality() {
268 assert!(constant_time_eq(b"abc", b"abc"));
269 assert!(!constant_time_eq(b"abc", b"abd"));
270 assert!(!constant_time_eq(b"abc", b"ab"));
271 assert!(constant_time_eq(b"", b""));
272 }
273}