Skip to main content

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}