Skip to main content

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}