cml/consistency.rs
1//! Consistency proof structure and verification — the append-only evolution
2//! surface layered over the spine's inclusion proof.
3//!
4//! Inclusion, the proof step, and the canonical-encoding security boundary live
5//! in the [`spine`] core and are re-exported here so a CML consumer reaches the
6//! whole structural proof surface through `cml::proof::*`. This module owns only
7//! what is append-only-specific and **epoch-free**: the [`ConsistencyProof`] (the
8//! tree at `old_size` is a prefix of the tree at `new_size`).
9//!
10//! The *temporal* analog over the committed epoch timeline (`verify_epoch_evolution`)
11//! and the coupling-wrapped consistency check are the `polydigest` combinator's
12//! concern — they need the timeline, which the spine and CML do not see.
13
14use spine::{ARITY_RANGE, Hasher, frontier_for_size};
15// Re-export the spine proof surface so `cml::proof::*` reaches it while the
16// originals live in `spine`. No parallels: these are the spine's, not copies.
17pub use spine::{
18 InclusionProof, ProofStep, constant_time_eq, reconstruct_inclusion_root, verify_inclusion,
19 verify_inclusion_path_structure,
20};
21
22use crate::mountain::{bag_peaks, mountain_skeleton};
23
24/// Consistency proof: proves tree at `old_size` is a prefix of tree at `new_size`.
25///
26/// MMR-native (increment-proof) shape: the old tree's rightmost peak is *included*
27/// at one slot of the new tree's frontier, and the new frontier peaks bag to the
28/// new root. Both authenticated roots are reconstructed from these fields plus the
29/// trusted `(old_size, new_size, arity)`, reusing the same inclusion and peak-bag
30/// primitives durability proves — no bespoke bisection trace, no coordinate map.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ConsistencyProof {
33 /// The old tree's rightmost frontier node — its last (smallest) peak.
34 pub boundary_hash: Vec<u8>,
35 /// Inclusion path lifting `boundary_hash` to `new_peaks[split_index]`, the new
36 /// frontier mountain that absorbed it. Its left-siblings are the older peaks
37 /// that merged into that mountain; its right-siblings are the appended cells.
38 pub peak_path: Vec<ProofStep>,
39 /// The new tree's frontier peaks, left to right (`frontier_for_size` order).
40 pub new_peaks: Vec<Vec<u8>>,
41 /// The slot of the boundary peak's mountain in `new_peaks`.
42 pub split_index: usize,
43}
44
45/// Verify a consistency proof.
46///
47/// Returns `true` if the proof demonstrates that the tree of size `old_size`
48/// with root `old_root` is an append-only prefix of the tree of size `new_size`
49/// with root `new_root` (arity `arity`).
50///
51/// # Trust contract (security-critical)
52///
53/// `old_size`, `new_size`, `arity`, `old_root`, and `new_root` are
54/// **trusted parameters** and MUST come from an authenticated source — signed
55/// Tree Heads (STHs) or trusted checkpoints — never from the proof or any
56/// caller-untrusted input. The verifier reconstructs both roots from the sizes
57/// and the proof fields (`boundary_hash`, `peak_path`, `new_peaks`,
58/// `split_index`); if the sizes are attacker-controlled the append-only
59/// guarantee is void.
60///
61/// Two further obligations follow from the length-hiding null-collapse design;
62/// violating them defeats the guarantee even with a correct verifier:
63///
64/// * **Root equality stands in for tree equality only when the size is also bound.** All-null
65/// (inactive) subtrees of *different* lengths share a root, so callers must never treat root
66/// equality as "same tree" without pinning the corresponding size. This applies to caches, dedup,
67/// and any comparison of stored/reconstructed roots.
68/// * **The data-level guarantee needs *both* roots authenticated.** A `true` result binds the roots
69/// (`old_root` is the genuine size-`old_size` prefix root of the size-`new_size` tree).
70/// Concluding that the new *data* is a genuine extension of the old data holds only when
71/// `new_root` is itself authenticated: a consistency proof carries perfect-subtree roots, not the
72/// appended cells, and cannot witness an extension against an unauthenticated `new_root`.
73#[must_use]
74#[allow(clippy::too_many_arguments)]
75pub fn verify_consistency(
76 hasher: &dyn Hasher,
77 old_size: u64,
78 new_size: u64,
79 arity: u64,
80 boundary_hash: &[u8],
81 peak_path: &[ProofStep],
82 new_peaks: &[Vec<u8>],
83 split_index: usize,
84 old_root: &[u8],
85 new_root: &[u8],
86) -> bool {
87 reconstruct_consistency_roots(
88 hasher,
89 old_size,
90 new_size,
91 arity,
92 boundary_hash,
93 peak_path,
94 new_peaks,
95 split_index,
96 )
97 .is_some_and(|(computed_old, computed_new)| {
98 constant_time_eq(&computed_old, old_root) & constant_time_eq(&computed_new, new_root)
99 })
100}
101
102/// Reconstruct the old and new raw roots from a consistency proof.
103///
104/// Building block for [`verify_consistency`]; it computes both roots but does
105/// not compare them to trusted ones. Callers must hold to the same trust
106/// contract: `old_size`, `new_size`, and `arity` must be authenticated, and
107/// the returned roots are only meaningful when checked against authenticated
108/// roots with the matching sizes bound (see [`verify_consistency`] for the
109/// length-binding and both-roots-authenticated obligations).
110///
111/// The reconstruction is purely *inclusion + peak-bag*:
112///
113/// 1. `boundary_hash` is lifted through `peak_path` to `new_peaks[split_index]` by
114/// [`reconstruct_inclusion_root`], pinned by the boundary mountain's slice of
115/// [`mountain_skeleton`]; the result must equal that peak.
116/// 2. the new root is [`bag_peaks`] over `new_peaks`.
117/// 3. the old root is [`bag_peaks`] over the old frontier peaks: the shared peaks left of the
118/// boundary mountain (`new_peaks[..split_index]`), the older peaks that merged into it (the
119/// left-siblings along `peak_path`, gathered highest-mountain first to match frontier order),
120/// and `boundary_hash` last.
121///
122/// Every old peak is therefore an *authenticated* slice of the inclusion proof or
123/// `new_peaks`, so a `true` consistency result binds the old root with no extra
124/// trusted state beyond the two roots.
125#[must_use]
126#[allow(clippy::too_many_arguments)]
127pub fn reconstruct_consistency_roots(
128 hasher: &dyn Hasher,
129 old_size: u64,
130 new_size: u64,
131 arity: u64,
132 boundary_hash: &[u8],
133 peak_path: &[ProofStep],
134 new_peaks: &[Vec<u8>],
135 split_index: usize,
136) -> Option<(Vec<u8>, Vec<u8>)> {
137 let digest_len = hasher.empty().len();
138 if digest_len == 0 || digest_len > 64 {
139 return None;
140 }
141 if boundary_hash.len() != digest_len {
142 return None;
143 }
144 if old_size == 0 || old_size >= new_size {
145 return None;
146 }
147 if !ARITY_RANGE.contains(&arity) {
148 return None;
149 }
150
151 let k = arity;
152 let old_coords = frontier_for_size(old_size, k);
153 let new_coords = frontier_for_size(new_size, k);
154
155 let &(boundary_left, boundary_height) = old_coords.last()?;
156
157 // `new_peaks` must be the new tree's frontier (bound to the trusted `new_size`)
158 // and every peak the right digest width, or the bag is meaningless.
159 if new_peaks.len() != new_coords.len() {
160 return None;
161 }
162 if split_index >= new_coords.len() {
163 return None;
164 }
165 if new_peaks.iter().any(|p| p.len() != digest_len) {
166 return None;
167 }
168
169 // The boundary peak must actually fall inside the mountain it claims.
170 let (new_left, new_height) = new_coords[split_index];
171 let cap = k.checked_pow(new_height)?;
172 let limit = new_left.checked_add(cap)?;
173 if boundary_left < new_left || boundary_left >= limit {
174 return None;
175 }
176 if new_height < boundary_height {
177 return None;
178 }
179
180 // 1. Inclusion of the boundary peak at `new_peaks[split_index]`.
181 //
182 // The boundary node sits at height `boundary_height`; its path to the mountain
183 // peak climbs heights `[boundary_height, new_height)`. That is exactly the
184 // upper portion of the mountain's leaf → peak skeleton — the leaf-aligned
185 // boundary subtree contributes the low `boundary_height` (all-zero) digits, so
186 // the climb is the skeleton slice past them. The bag steps above the peak are
187 // excluded: this proves inclusion *at the peak*, not at the root.
188 let full_skeleton = mountain_skeleton(k, new_size, boundary_left)?;
189 let bh = boundary_height as usize;
190 let nh = new_height as usize;
191 if full_skeleton.len() < nh {
192 return None;
193 }
194 let climb = &full_skeleton[bh..nh];
195 if peak_path.len() != climb.len() {
196 return None;
197 }
198 let recovered = reconstruct_inclusion_root(hasher, boundary_hash, climb, peak_path)?;
199 if !constant_time_eq(&recovered, &new_peaks[split_index]) {
200 return None;
201 }
202
203 // 2. The new root is the bag of the new frontier peaks (unchanged fold).
204 let computed_new_root = bag_peaks(hasher, new_peaks, k);
205
206 // 3. The old root is the bag of the old frontier peaks. They are, in frontier
207 // (decreasing-height) order: the peaks left of the boundary mountain — shared
208 // verbatim with the new tree — then the older peaks that merged into the
209 // boundary mountain, then the boundary peak itself. The merged peaks are the
210 // left-siblings along the climb: at each step the children before the path
211 // node lie wholly within the old tree (the right-siblings are appended cells).
212 // Climbing runs boundary → peak (lowest height first), so iterate `peak_path`
213 // in reverse to take the highest mountains first and preserve frontier order.
214 let mut old_peaks: Vec<Vec<u8>> = new_peaks[..split_index].to_vec();
215 for step in peak_path.iter().rev() {
216 let left = step.position.min(step.siblings.len());
217 for sib in &step.siblings[..left] {
218 old_peaks.push(sib.clone());
219 }
220 }
221 old_peaks.push(boundary_hash.to_vec());
222 let computed_old_root = bag_peaks(hasher, &old_peaks, k);
223
224 Some((computed_old_root, computed_new_root))
225}