Skip to main content

chio_kernel/
checkpoint.rs

1//! Merkle-committed receipt batch checkpointing.
2//!
3//! Produces signed kernel checkpoint statements that commit a batch of receipts
4//! to a Merkle root. Inclusion proofs allow verifying that a specific receipt
5//! was part of a batch without replaying the entire log.
6//!
7//! Issuance schema: "chio.checkpoint_statement.v2"
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use chio_core::canonical::canonical_json_bytes;
13use chio_core::crypto::{Keypair, PublicKey, Signature, SigningAlgorithm};
14use chio_core::hashing::sha256_hex;
15use chio_core::hashing::Hash;
16use chio_core::merkle::{leaf_hash, verify_consistency_proof, MerkleProof, MerkleTree};
17use chio_core::receipt::{
18    checkpoint::CheckpointPublicationIdentityKind,
19    checkpoint::CheckpointPublicationTrustAnchorBinding,
20};
21use serde::{Deserialize, Deserializer, Serialize};
22
23use crate::ReceiptStoreError;
24
25#[path = "checkpoint/consistency.rs"]
26mod consistency;
27pub use consistency::CheckpointConsistencyAnchor;
28use consistency::{chain_leaf_is_committed, consistency_prefix_is_anchored};
29
30#[cfg(test)]
31std::thread_local! {
32    static CHECKPOINT_SIGNATURE_VERIFICATION_COUNT: std::cell::Cell<usize> =
33        const { std::cell::Cell::new(0) };
34    static CHECKPOINT_EQUIVOCATION_INSPECTION_COUNT: std::cell::Cell<usize> =
35        const { std::cell::Cell::new(0) };
36}
37
38/// Legacy checkpoint statement without a checkpoint-chain commitment.
39pub const CHECKPOINT_SCHEMA_V1: &str = "chio.checkpoint_statement.v1";
40/// Checkpoint statement that may carry the signed checkpoint-chain commitment.
41pub const CHECKPOINT_SCHEMA_V2: &str = "chio.checkpoint_statement.v2";
42/// Schema used for new checkpoint issuance.
43pub const CHECKPOINT_SCHEMA: &str = CHECKPOINT_SCHEMA_V2;
44pub const CHECKPOINT_PUBLICATION_SCHEMA: &str = "chio.checkpoint_publication.v1";
45pub const CHECKPOINT_WITNESS_SCHEMA: &str = "chio.checkpoint_witness.v1";
46/// Legacy metadata-only continuity record.
47pub const CHECKPOINT_CONSISTENCY_PROOF_SCHEMA_V1: &str = "chio.checkpoint_consistency_proof.v1";
48/// Cryptographic checkpoint-chain consistency proof.
49pub const CHECKPOINT_CONSISTENCY_PROOF_SCHEMA_V2: &str = "chio.checkpoint_consistency_proof.v2";
50/// Schema used for new consistency-proof issuance.
51pub const CHECKPOINT_CONSISTENCY_PROOF_SCHEMA: &str = CHECKPOINT_CONSISTENCY_PROOF_SCHEMA_V2;
52pub const CHECKPOINT_EQUIVOCATION_SCHEMA: &str = "chio.checkpoint_equivocation.v1";
53
54#[must_use]
55pub fn is_supported_checkpoint_schema(schema: &str) -> bool {
56    matches!(schema, CHECKPOINT_SCHEMA_V1 | CHECKPOINT_SCHEMA_V2)
57}
58
59fn deserialize_non_null_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
60where
61    D: Deserializer<'de>,
62    T: serde::de::DeserializeOwned,
63{
64    let value = serde_json::Value::deserialize(deserializer)?;
65    if value.is_null() {
66        return Err(<D::Error as serde::de::Error>::custom(
67            "explicit null is not permitted; omit the optional field",
68        ));
69    }
70    T::deserialize(value)
71        .map(Some)
72        .map_err(<D::Error as serde::de::Error>::custom)
73}
74
75/// Error type for checkpoint operations.
76#[derive(Debug, thiserror::Error)]
77pub enum CheckpointError {
78    #[error("merkle error: {0}")]
79    Merkle(#[from] chio_core::Error),
80    #[error("serialization error: {0}")]
81    Serialization(String),
82    #[error("signing error: {0}")]
83    Signing(String),
84    #[error("sqlite error: {0}")]
85    Sqlite(#[from] rusqlite::Error),
86    #[error("json error: {0}")]
87    Json(#[from] serde_json::Error),
88    #[error("receipt store error: {0}")]
89    ReceiptStore(#[from] ReceiptStoreError),
90    #[error("invalid checkpoint: {0}")]
91    Invalid(String),
92    #[error("checkpoint signature verification failed")]
93    InvalidSignature,
94    #[error("checkpoint continuity error: {0}")]
95    Continuity(String),
96}
97
98/// The signed body of a kernel checkpoint statement.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct KernelCheckpointBody {
102    /// Schema identifier for new checkpoint issuance.
103    pub schema: String,
104    /// Monotonic checkpoint counter.
105    pub checkpoint_seq: u64,
106    /// First receipt seq in this batch.
107    pub batch_start_seq: u64,
108    /// Last receipt seq in this batch.
109    pub batch_end_seq: u64,
110    /// Number of leaves in the Merkle tree.
111    pub tree_size: usize,
112    /// Root from MerkleTree::from_leaves.
113    pub merkle_root: Hash,
114    /// Unix timestamp (seconds) when the checkpoint was issued.
115    pub issued_at: u64,
116    /// The kernel's signing key (public).
117    pub kernel_key: PublicKey,
118    /// Hash of the immediately preceding checkpoint body when this checkpoint extends a prior batch.
119    #[serde(
120        default,
121        skip_serializing_if = "Option::is_none",
122        deserialize_with = "deserialize_non_null_option"
123    )]
124    pub previous_checkpoint_sha256: Option<String>,
125    /// RFC 6962 root over the checkpoint-chain leaves for checkpoint_seq 1
126    /// through this checkpoint, one leaf per checkpoint binding its sequence,
127    /// entry range, and batch root (see [`checkpoint_chain_leaf_hash`]). This
128    /// is the commitment that consistency proofs verify against. Absent on
129    /// v1 checkpoints and on detached v2 checkpoints built without chain
130    /// context.
131    #[serde(
132        default,
133        skip_serializing_if = "Option::is_none",
134        deserialize_with = "deserialize_non_null_option"
135    )]
136    pub chain_root: Option<Hash>,
137}
138
139/// A signed kernel checkpoint statement.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct KernelCheckpoint {
143    /// The signed body.
144    pub body: KernelCheckpointBody,
145    /// Ed25519 signature over canonical JSON of `body`.
146    pub signature: Signature,
147}
148
149/// A Merkle inclusion proof for a receipt within a checkpoint batch.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ReceiptInclusionProof {
152    /// Which checkpoint this proof is for.
153    pub checkpoint_seq: u64,
154    /// The seq of the receipt being proved.
155    pub receipt_seq: u64,
156    /// Index of this receipt in the Merkle leaf array.
157    pub leaf_index: usize,
158    /// The Merkle root this proof is against.
159    pub merkle_root: Hash,
160    /// The audit path proof.
161    pub proof: MerkleProof,
162}
163
164impl ReceiptInclusionProof {
165    /// Verify that `receipt_canonical_bytes` is included in the batch.
166    #[must_use]
167    pub fn verify(&self, receipt_canonical_bytes: &[u8], expected_root: &Hash) -> bool {
168        self.proof.verify(receipt_canonical_bytes, expected_root)
169    }
170}
171
172/// A deterministic publication record derived from a signed checkpoint.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct CheckpointPublication {
175    /// Local log identity derived from the checkpoint signing key until an
176    /// explicit persisted transparency log ID is available.
177    pub log_id: String,
178    /// Schema identifier for derived publication records.
179    pub schema: String,
180    /// Monotonic checkpoint counter.
181    pub checkpoint_seq: u64,
182    /// Canonical SHA-256 digest of the signed checkpoint body.
183    pub checkpoint_sha256: String,
184    /// Merkle root published by the checkpoint.
185    pub merkle_root: Hash,
186    /// Timestamp when the checkpoint was issued/published.
187    pub published_at: u64,
188    /// The kernel key that signed the checkpoint.
189    pub kernel_key: PublicKey,
190    /// Cumulative log size derived from the covered entry sequence range.
191    pub log_tree_size: u64,
192    /// First entry sequence covered by this checkpoint batch.
193    pub entry_start_seq: u64,
194    /// Last entry sequence covered by this checkpoint batch.
195    pub entry_end_seq: u64,
196    /// Digest of the predecessor checkpoint body when present.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub previous_checkpoint_sha256: Option<String>,
199    /// Declared verifier material when this publication is tied to a typed
200    /// publication path and explicit trust-anchor policy.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub trust_anchor_binding: Option<CheckpointPublicationTrustAnchorBinding>,
203}
204
205/// A deterministic witness record derived from a checkpoint's predecessor digest.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct CheckpointWitness {
208    /// Local log identity derived from the checkpoint signing key.
209    pub log_id: String,
210    /// Schema identifier for derived witness records.
211    pub schema: String,
212    /// The checkpoint being witnessed.
213    pub checkpoint_seq: u64,
214    /// Canonical SHA-256 digest of the witnessed checkpoint body.
215    pub checkpoint_sha256: String,
216    /// The later checkpoint that cites the witnessed checkpoint digest.
217    pub witness_checkpoint_seq: u64,
218    /// Canonical SHA-256 digest of the witness checkpoint body.
219    pub witness_checkpoint_sha256: String,
220    /// Timestamp from the witness checkpoint body.
221    pub witnessed_at: u64,
222}
223
224/// A Merkle consistency proof between two checkpoint-chain commitments.
225///
226/// Proves, with RFC 6962 node hashes, that the checkpoint chain committed by
227/// `to_chain_root` is an append-only extension of the chain committed by
228/// `from_chain_root`. The chain tree has one leaf per checkpoint, so the tree
229/// sizes are the checkpoint sequences themselves.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct CheckpointConsistencyProof {
233    /// Schema identifier for consistency proof records.
234    pub schema: String,
235    /// Local log identity derived from the checkpoint signing key.
236    pub log_id: String,
237    /// Earlier checkpoint sequence in the proven prefix chain.
238    pub from_checkpoint_seq: u64,
239    /// Later checkpoint sequence in the proven prefix chain.
240    pub to_checkpoint_seq: u64,
241    /// Canonical SHA-256 digest of the earlier checkpoint body.
242    pub from_checkpoint_sha256: String,
243    /// Canonical SHA-256 digest of the later checkpoint body.
244    pub to_checkpoint_sha256: String,
245    /// Cumulative log size before the append.
246    pub from_log_tree_size: u64,
247    /// Cumulative log size after the append.
248    pub to_log_tree_size: u64,
249    /// First entry sequence appended by the later checkpoint.
250    pub appended_entry_start_seq: u64,
251    /// Last entry sequence appended by the later checkpoint.
252    pub appended_entry_end_seq: u64,
253    /// Signed chain commitment of the earlier checkpoint. Absent on legacy v1
254    /// metadata-only records.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub from_chain_root: Option<Hash>,
257    /// Signed chain commitment of the later checkpoint. Absent on legacy v1
258    /// metadata-only records.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub to_chain_root: Option<Hash>,
261    /// RFC 6962 consistency path from the earlier chain tree to the later
262    /// chain tree.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub chain_proof_hashes: Vec<Hash>,
265    /// Inclusion proof binding the earlier checkpoint's own chain leaf to
266    /// `from_chain_root` at the last position of that tree.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub from_leaf_inclusion: Option<MerkleProof>,
269    /// Inclusion proof binding the later checkpoint's own chain leaf to
270    /// `to_chain_root` at the last position. Without both endpoints bound, a
271    /// key holder could commit chain trees whose leaves are unrelated to the
272    /// bodies the proof names and still produce a verifying consistency path.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub to_leaf_inclusion: Option<MerkleProof>,
275}
276
277/// Classifies a conflicting checkpoint observation.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum CheckpointEquivocationKind {
281    /// Two distinct checkpoints claim the same checkpoint sequence.
282    ConflictingCheckpointSeq,
283    /// Two distinct checkpoints claim the same log and cumulative tree size.
284    ConflictingLogTreeSize,
285    /// Two distinct checkpoints cite the same predecessor digest.
286    ConflictingPredecessorWitness,
287}
288
289/// A deterministic conflict record derived from multiple checkpoint statements.
290#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
291pub struct CheckpointEquivocation {
292    /// Schema identifier for derived equivocation records.
293    pub schema: String,
294    /// Which transparency rule was violated.
295    pub kind: CheckpointEquivocationKind,
296    /// Local log identity when the conflict can be tied to one derived log.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub log_id: Option<String>,
299    /// Shared cumulative log size when the conflict is a tree-size fork.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub log_tree_size: Option<u64>,
302    /// The first conflicting checkpoint sequence.
303    pub first_checkpoint_seq: u64,
304    /// The second conflicting checkpoint sequence.
305    pub second_checkpoint_seq: u64,
306    /// Canonical SHA-256 digest of the first checkpoint body.
307    pub first_checkpoint_sha256: String,
308    /// Canonical SHA-256 digest of the second checkpoint body.
309    pub second_checkpoint_sha256: String,
310    /// Shared predecessor digest when the conflict is a witness fork.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub previous_checkpoint_sha256: Option<String>,
313}
314
315/// Derived transparency records for a set of checkpoints.
316#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
317pub struct CheckpointTransparencySummary {
318    /// Publication records for each checkpoint.
319    pub publications: Vec<CheckpointPublication>,
320    /// Witness records derived from predecessor-digest links.
321    pub witnesses: Vec<CheckpointWitness>,
322    /// Prefix-growth proofs derived from contiguous checkpoint extensions.
323    pub consistency_proofs: Vec<CheckpointConsistencyProof>,
324    /// Conflict records derived from contradictory checkpoints.
325    pub equivocations: Vec<CheckpointEquivocation>,
326}
327
328#[must_use]
329pub fn checkpoint_log_id(checkpoint: &KernelCheckpoint) -> String {
330    let log_key_bytes: Vec<u8> = match checkpoint.body.kernel_key.algorithm() {
331        SigningAlgorithm::Ed25519 => checkpoint.body.kernel_key.as_bytes().to_vec(),
332        SigningAlgorithm::P256 | SigningAlgorithm::P384 | SigningAlgorithm::Hybrid => {
333            checkpoint.body.kernel_key.to_hex().into_bytes()
334        }
335    };
336    format!("local-log-{}", sha256_hex(&log_key_bytes))
337}
338
339#[must_use]
340pub fn checkpoint_log_tree_size(body: &KernelCheckpointBody) -> u64 {
341    body.batch_end_seq
342}
343
344fn checkpoint_batch_entry_count(body: &KernelCheckpointBody) -> Result<u64, CheckpointError> {
345    body.batch_end_seq
346        .checked_sub(body.batch_start_seq)
347        .and_then(|count| count.checked_add(1))
348        .ok_or_else(|| {
349            CheckpointError::Invalid(format!(
350                "invalid checkpoint entry range {}-{}",
351                body.batch_start_seq, body.batch_end_seq
352            ))
353        })
354}
355
356/// Return the canonical SHA-256 digest for a checkpoint body.
357pub fn checkpoint_body_sha256(body: &KernelCheckpointBody) -> Result<String, CheckpointError> {
358    let body_bytes =
359        canonical_json_bytes(body).map_err(|e| CheckpointError::Serialization(e.to_string()))?;
360    Ok(sha256_hex(&body_bytes))
361}
362
363/// Canonical leaf content for the checkpoint-chain commitment.
364///
365/// Deliberately excludes `issued_at`, the kernel key, and the signature so
366/// that two honest builders checkpointing the same batch produce the same
367/// leaf even when their wall clocks differ.
368#[derive(Serialize)]
369struct CheckpointChainLeaf {
370    checkpoint_seq: u64,
371    batch_start_seq: u64,
372    batch_end_seq: u64,
373    merkle_root: Hash,
374}
375
376/// RFC 6962 leaf hash of one checkpoint's chain-commitment leaf.
377pub fn checkpoint_chain_leaf_hash_from_parts(
378    checkpoint_seq: u64,
379    batch_start_seq: u64,
380    batch_end_seq: u64,
381    merkle_root: Hash,
382) -> Result<Hash, CheckpointError> {
383    let leaf = CheckpointChainLeaf {
384        checkpoint_seq,
385        batch_start_seq,
386        batch_end_seq,
387        merkle_root,
388    };
389    let leaf_bytes =
390        canonical_json_bytes(&leaf).map_err(|e| CheckpointError::Serialization(e.to_string()))?;
391    Ok(leaf_hash(&leaf_bytes))
392}
393
394/// RFC 6962 leaf hash of a checkpoint body's chain-commitment leaf.
395pub fn checkpoint_chain_leaf_hash(body: &KernelCheckpointBody) -> Result<Hash, CheckpointError> {
396    checkpoint_chain_leaf_hash_from_parts(
397        body.checkpoint_seq,
398        body.batch_start_seq,
399        body.batch_end_seq,
400        body.merkle_root,
401    )
402}
403
404/// Append-only frontier of the checkpoint-chain Merkle tree.
405///
406/// Holds one root per perfect subtree covering the leaves so far, largest
407/// first, which is enough to compute the RFC 6962 tree head and to extend it.
408/// Appending is amortized O(1) hashes and the root costs O(log n), so a
409/// long-lived writer never rehashes the whole chain to issue one checkpoint.
410#[derive(Debug, Clone, Default, PartialEq, Eq)]
411pub struct CheckpointChainFrontier {
412    /// `(subtree root, leaf span)`, spans strictly decreasing powers of two.
413    subtrees: Vec<(Hash, u64)>,
414}
415
416impl CheckpointChainFrontier {
417    /// Frontier over no leaves.
418    #[must_use]
419    pub fn empty() -> Self {
420        Self::default()
421    }
422
423    /// Rebuild from every chain leaf in order. O(n); used once when a writer
424    /// seeds or resyncs its head, never on the per-checkpoint path.
425    #[must_use]
426    pub fn from_leaves(chain_leaf_hashes: &[Hash]) -> Self {
427        let mut frontier = Self::empty();
428        for leaf in chain_leaf_hashes {
429            frontier.append(*leaf);
430        }
431        frontier
432    }
433
434    /// Number of chain leaves covered.
435    #[must_use]
436    pub fn leaf_count(&self) -> u64 {
437        self.subtrees.iter().map(|(_, span)| *span).sum()
438    }
439
440    /// Extend by one chain leaf, merging equal-span neighbours so the spans
441    /// stay strictly decreasing powers of two.
442    pub fn append(&mut self, chain_leaf_hash: Hash) {
443        self.subtrees.push((chain_leaf_hash, 1));
444        while self.subtrees.len() >= 2 {
445            let (right, right_span) = self.subtrees[self.subtrees.len() - 1];
446            let (left, left_span) = self.subtrees[self.subtrees.len() - 2];
447            if left_span != right_span {
448                break;
449            }
450            self.subtrees.truncate(self.subtrees.len() - 2);
451            self.subtrees
452                .push((chio_core::merkle::node_hash(&left, &right), left_span * 2));
453        }
454    }
455
456    /// Number of perfect subtrees retained; equals the population count of
457    /// the leaf count.
458    #[cfg(test)]
459    #[must_use]
460    pub fn subtree_count_for_test(&self) -> usize {
461        self.subtrees.len()
462    }
463
464    /// RFC 6962 tree head over the covered leaves, or `None` when empty.
465    ///
466    /// Folds right-associatively, which is the same shape the recursive
467    /// definition produces for a tree whose right edge is incomplete.
468    #[must_use]
469    pub fn root(&self) -> Option<Hash> {
470        let (last, _) = *self.subtrees.last()?;
471        Some(
472            self.subtrees[..self.subtrees.len() - 1]
473                .iter()
474                .rev()
475                .fold(last, |acc, (subtree, _)| {
476                    chio_core::merkle::node_hash(subtree, &acc)
477                }),
478        )
479    }
480}
481
482/// Chain-commitment root over an ordered, gap-free run of chain leaves
483/// starting at checkpoint_seq 1.
484pub fn checkpoint_chain_root(chain_leaf_hashes: &[Hash]) -> Result<Hash, CheckpointError> {
485    Ok(MerkleTree::from_hashes(chain_leaf_hashes.to_vec())?.root())
486}
487
488/// Build a deterministic publication record from a signed checkpoint.
489pub fn build_checkpoint_publication(
490    checkpoint: &KernelCheckpoint,
491) -> Result<CheckpointPublication, CheckpointError> {
492    validate_checkpoint(checkpoint)?;
493    Ok(CheckpointPublication {
494        log_id: checkpoint_log_id(checkpoint),
495        schema: CHECKPOINT_PUBLICATION_SCHEMA.to_string(),
496        checkpoint_seq: checkpoint.body.checkpoint_seq,
497        checkpoint_sha256: checkpoint_body_sha256(&checkpoint.body)?,
498        merkle_root: checkpoint.body.merkle_root,
499        published_at: checkpoint.body.issued_at,
500        kernel_key: checkpoint.body.kernel_key.clone(),
501        log_tree_size: checkpoint_log_tree_size(&checkpoint.body),
502        entry_start_seq: checkpoint.body.batch_start_seq,
503        entry_end_seq: checkpoint.body.batch_end_seq,
504        previous_checkpoint_sha256: checkpoint.body.previous_checkpoint_sha256.clone(),
505        trust_anchor_binding: None,
506    })
507}
508
509/// Attach a validated trust-anchor binding to an already-derived publication.
510pub fn bind_checkpoint_publication_trust_anchor(
511    publication: CheckpointPublication,
512    trust_anchor_binding: CheckpointPublicationTrustAnchorBinding,
513) -> Result<CheckpointPublication, CheckpointError> {
514    trust_anchor_binding
515        .validate()
516        .map_err(|error| CheckpointError::Invalid(error.to_string()))?;
517    bind_checkpoint_publication_trust_anchor_after_validation(publication, trust_anchor_binding)
518}
519
520fn bind_checkpoint_publication_trust_anchor_after_validation(
521    mut publication: CheckpointPublication,
522    trust_anchor_binding: CheckpointPublicationTrustAnchorBinding,
523) -> Result<CheckpointPublication, CheckpointError> {
524    if trust_anchor_binding.publication_identity.kind == CheckpointPublicationIdentityKind::LocalLog
525        && trust_anchor_binding.publication_identity.identity != publication.log_id
526    {
527        return Err(CheckpointError::Invalid(format!(
528            "checkpoint publication local_log identity {} does not match log_id {}",
529            trust_anchor_binding.publication_identity.identity, publication.log_id
530        )));
531    }
532    publication.trust_anchor_binding = Some(trust_anchor_binding);
533    Ok(publication)
534}
535
536/// Build a deterministic publication record that is explicitly bound to
537/// declared trust-anchor verifier material.
538pub fn build_trust_anchored_checkpoint_publication(
539    checkpoint: &KernelCheckpoint,
540    trust_anchor_binding: CheckpointPublicationTrustAnchorBinding,
541) -> Result<CheckpointPublication, CheckpointError> {
542    trust_anchor_binding
543        .validate()
544        .map_err(|error| CheckpointError::Invalid(error.to_string()))?;
545    let publication = build_checkpoint_publication(checkpoint)?;
546    bind_checkpoint_publication_trust_anchor_after_validation(publication, trust_anchor_binding)
547}
548
549/// Build a deterministic witness record when `witness_checkpoint` cites `checkpoint`.
550pub fn build_checkpoint_witness(
551    checkpoint: &KernelCheckpoint,
552    witness_checkpoint: &KernelCheckpoint,
553) -> Result<CheckpointWitness, CheckpointError> {
554    validate_checkpoint(checkpoint)?;
555    validate_checkpoint(witness_checkpoint)?;
556
557    let checkpoint_sha256 = checkpoint_body_sha256(&checkpoint.body)?;
558    let witness_checkpoint_sha256 = checkpoint_body_sha256(&witness_checkpoint.body)?;
559    let Some(previous_checkpoint_sha256) = witness_checkpoint
560        .body
561        .previous_checkpoint_sha256
562        .as_deref()
563    else {
564        return Err(CheckpointError::Continuity(format!(
565            "checkpoint {} does not cite a predecessor digest",
566            witness_checkpoint.body.checkpoint_seq
567        )));
568    };
569    if previous_checkpoint_sha256 != checkpoint_sha256 {
570        return Err(CheckpointError::Continuity(format!(
571            "checkpoint {} does not witness checkpoint {}",
572            witness_checkpoint.body.checkpoint_seq, checkpoint.body.checkpoint_seq
573        )));
574    }
575
576    Ok(CheckpointWitness {
577        log_id: checkpoint_log_id(checkpoint),
578        schema: CHECKPOINT_WITNESS_SCHEMA.to_string(),
579        checkpoint_seq: checkpoint.body.checkpoint_seq,
580        checkpoint_sha256,
581        witness_checkpoint_seq: witness_checkpoint.body.checkpoint_seq,
582        witness_checkpoint_sha256,
583        witnessed_at: witness_checkpoint.body.issued_at,
584    })
585}
586
587fn require_chain_root(checkpoint: &KernelCheckpoint) -> Result<Hash, CheckpointError> {
588    checkpoint.body.chain_root.ok_or_else(|| {
589        CheckpointError::Continuity(format!(
590            "checkpoint {} carries no chain commitment; consistency is unverifiable",
591            checkpoint.body.checkpoint_seq
592        ))
593    })
594}
595
596fn chain_tree_size(checkpoint: &KernelCheckpoint) -> Result<usize, CheckpointError> {
597    usize::try_from(checkpoint.body.checkpoint_seq).map_err(|_| {
598        CheckpointError::Invalid(format!(
599            "checkpoint_seq {} exceeds the addressable chain size",
600            checkpoint.body.checkpoint_seq
601        ))
602    })
603}
604
605/// Ensure the two pair endpoints appear at their own positions in the
606/// supplied chain leaves, then hand back the parsed sizes.
607fn validate_chain_leaves_for_pair(
608    previous: &KernelCheckpoint,
609    current: &KernelCheckpoint,
610    chain_leaf_hashes: &[Hash],
611) -> Result<(usize, usize), CheckpointError> {
612    let from_size = chain_tree_size(previous)?;
613    let to_size = chain_tree_size(current)?;
614    // Callers reach here through `validate_checkpoint_predecessor`, which
615    // forces `from_size + 1 == to_size`; re-check locally so a future direct
616    // caller gets an error rather than an out-of-bounds index below.
617    if from_size == 0 || from_size >= to_size {
618        return Err(CheckpointError::Continuity(format!(
619            "checkpoint {} does not extend checkpoint {} in chain order",
620            current.body.checkpoint_seq, previous.body.checkpoint_seq
621        )));
622    }
623    if chain_leaf_hashes.len() < to_size {
624        return Err(CheckpointError::Continuity(format!(
625            "chain leaf count {} does not reach checkpoint {} chain size {}",
626            chain_leaf_hashes.len(),
627            current.body.checkpoint_seq,
628            to_size
629        )));
630    }
631    if chain_leaf_hashes[from_size - 1] != checkpoint_chain_leaf_hash(&previous.body)? {
632        return Err(CheckpointError::Continuity(format!(
633            "chain leaf {} does not match the predecessor checkpoint body",
634            previous.body.checkpoint_seq
635        )));
636    }
637    if chain_leaf_hashes[to_size - 1] != checkpoint_chain_leaf_hash(&current.body)? {
638        return Err(CheckpointError::Continuity(format!(
639            "chain leaf {} does not match the checkpoint body",
640            current.body.checkpoint_seq
641        )));
642    }
643    Ok((from_size, to_size))
644}
645
646fn validate_checkpoint_chain_commitments(
647    previous: &KernelCheckpoint,
648    current: &KernelCheckpoint,
649    chain_leaf_hashes: &[Hash],
650    chain_tree: &MerkleTree,
651) -> Result<(usize, usize), CheckpointError> {
652    let (from_size, to_size) =
653        validate_chain_leaves_for_pair(previous, current, chain_leaf_hashes)?;
654    if let Some(from_chain_root) = previous.body.chain_root {
655        if chain_tree.prefix_root(from_size)? != from_chain_root {
656            return Err(CheckpointError::Continuity(format!(
657                "predecessor {} chain_root does not match the retained checkpoint chain",
658                previous.body.checkpoint_seq
659            )));
660        }
661    }
662    if let Some(to_chain_root) = current.body.chain_root {
663        if chain_tree.prefix_root(to_size)? != to_chain_root {
664            return Err(CheckpointError::Continuity(format!(
665                "checkpoint {} chain_root does not extend the retained checkpoint chain",
666                current.body.checkpoint_seq
667            )));
668        }
669    }
670    Ok((from_size, to_size))
671}
672
673fn build_checkpoint_consistency_proof_from_tree(
674    previous: &KernelCheckpoint,
675    current: &KernelCheckpoint,
676    chain_leaf_hashes: &[Hash],
677    chain_tree: &MerkleTree,
678) -> Result<CheckpointConsistencyProof, CheckpointError> {
679    validate_checkpoint_predecessor(previous, current)?;
680    let previous = ValidatedCheckpoint::after_validation(previous)?;
681    let current = ValidatedCheckpoint::after_validation(current)?;
682    build_checkpoint_consistency_proof_from_validated(
683        &previous,
684        &current,
685        chain_leaf_hashes,
686        chain_tree,
687    )
688}
689
690fn build_checkpoint_consistency_proof_from_validated(
691    previous: &ValidatedCheckpoint<'_>,
692    current: &ValidatedCheckpoint<'_>,
693    chain_leaf_hashes: &[Hash],
694    chain_tree: &MerkleTree,
695) -> Result<CheckpointConsistencyProof, CheckpointError> {
696    validate_checkpoint_predecessor_link(
697        previous.checkpoint,
698        &previous.checkpoint_sha256,
699        current.checkpoint,
700    )?;
701    if previous.log_id != current.log_id {
702        return Err(CheckpointError::Continuity(format!(
703            "checkpoint {} derives log_id {} but predecessor {} derives {}",
704            current.checkpoint.body.checkpoint_seq,
705            current.log_id,
706            previous.checkpoint.body.checkpoint_seq,
707            previous.log_id
708        )));
709    }
710
711    let from_chain_root = require_chain_root(previous.checkpoint)?;
712    let to_chain_root = require_chain_root(current.checkpoint)?;
713    let (from_size, to_size) = validate_checkpoint_chain_commitments(
714        previous.checkpoint,
715        current.checkpoint,
716        chain_leaf_hashes,
717        chain_tree,
718    )?;
719    let chain_proof_hashes = chain_tree.consistency_proof_between(from_size, to_size)?;
720    let from_leaf_inclusion = chain_tree.inclusion_proof_at_size(from_size - 1, from_size)?;
721    let to_leaf_inclusion = chain_tree.inclusion_proof_at_size(to_size - 1, to_size)?;
722
723    Ok(CheckpointConsistencyProof {
724        schema: CHECKPOINT_CONSISTENCY_PROOF_SCHEMA.to_string(),
725        log_id: current.log_id.clone(),
726        from_checkpoint_seq: previous.checkpoint.body.checkpoint_seq,
727        to_checkpoint_seq: current.checkpoint.body.checkpoint_seq,
728        from_checkpoint_sha256: previous.checkpoint_sha256.clone(),
729        to_checkpoint_sha256: current.checkpoint_sha256.clone(),
730        from_log_tree_size: previous.log_tree_size,
731        to_log_tree_size: current.log_tree_size,
732        appended_entry_start_seq: current.checkpoint.body.batch_start_seq,
733        appended_entry_end_seq: current.checkpoint.body.batch_end_seq,
734        from_chain_root: Some(from_chain_root),
735        to_chain_root: Some(to_chain_root),
736        chain_proof_hashes,
737        from_leaf_inclusion: Some(from_leaf_inclusion),
738        to_leaf_inclusion: Some(to_leaf_inclusion),
739    })
740}
741
742fn build_legacy_checkpoint_consistency_record_from_validated(
743    previous: &ValidatedCheckpoint<'_>,
744    current: &ValidatedCheckpoint<'_>,
745) -> Result<CheckpointConsistencyProof, CheckpointError> {
746    validate_checkpoint_predecessor_link(
747        previous.checkpoint,
748        &previous.checkpoint_sha256,
749        current.checkpoint,
750    )?;
751    if previous.log_id != current.log_id {
752        return Err(CheckpointError::Continuity(format!(
753            "checkpoint {} derives log_id {} but predecessor {} derives {}",
754            current.checkpoint.body.checkpoint_seq,
755            current.log_id,
756            previous.checkpoint.body.checkpoint_seq,
757            previous.log_id
758        )));
759    }
760    if previous.checkpoint.body.schema != CHECKPOINT_SCHEMA_V1
761        || current.checkpoint.body.schema != CHECKPOINT_SCHEMA_V1
762        || previous.checkpoint.body.chain_root.is_some()
763        || current.checkpoint.body.chain_root.is_some()
764    {
765        return Err(CheckpointError::Invalid(
766            "legacy consistency records require two v1 checkpoints without chain commitments"
767                .to_string(),
768        ));
769    }
770
771    Ok(CheckpointConsistencyProof {
772        schema: CHECKPOINT_CONSISTENCY_PROOF_SCHEMA_V1.to_string(),
773        log_id: current.log_id.clone(),
774        from_checkpoint_seq: previous.checkpoint.body.checkpoint_seq,
775        to_checkpoint_seq: current.checkpoint.body.checkpoint_seq,
776        from_checkpoint_sha256: previous.checkpoint_sha256.clone(),
777        to_checkpoint_sha256: current.checkpoint_sha256.clone(),
778        from_log_tree_size: previous.log_tree_size,
779        to_log_tree_size: current.log_tree_size,
780        appended_entry_start_seq: current.checkpoint.body.batch_start_seq,
781        appended_entry_end_seq: current.checkpoint.body.batch_end_seq,
782        from_chain_root: None,
783        to_chain_root: None,
784        chain_proof_hashes: Vec::new(),
785        from_leaf_inclusion: None,
786        to_leaf_inclusion: None,
787    })
788}
789
790/// Build a Merkle consistency proof showing that `current`'s chain commitment
791/// is an append-only extension of `previous`'s.
792///
793/// `chain_leaf_hashes` must contain the chain leaf of every checkpoint from
794/// sequence 1 through `current`, in order (see
795/// [`checkpoint_chain_leaf_hash`]). Both checkpoints must carry a signed
796/// `chain_root` and both roots must match the supplied leaves; the proof
797/// fails to build rather than committing to unverified data.
798pub fn build_checkpoint_consistency_proof(
799    previous: &KernelCheckpoint,
800    current: &KernelCheckpoint,
801    chain_leaf_hashes: &[Hash],
802) -> Result<CheckpointConsistencyProof, CheckpointError> {
803    let to_size = chain_tree_size(current)?;
804    if chain_leaf_hashes.len() != to_size {
805        return Err(CheckpointError::Continuity(format!(
806            "chain leaf count {} does not match checkpoint {} chain size {}",
807            chain_leaf_hashes.len(),
808            current.body.checkpoint_seq,
809            to_size
810        )));
811    }
812    let tree = MerkleTree::from_hashes(chain_leaf_hashes.to_vec())?;
813    build_checkpoint_consistency_proof_from_tree(previous, current, chain_leaf_hashes, &tree)
814}
815
816/// Verify a genesis-anchored consistency proof against two signed checkpoints.
817///
818/// The earlier endpoint must be checkpoint 1; a pair that starts later carries
819/// a prefix this input set cannot judge, so it is an error here. Mid-chain
820/// pairs go through [`verify_checkpoint_consistency_proof_with_anchor`].
821pub fn verify_checkpoint_consistency_proof(
822    previous: &KernelCheckpoint,
823    current: &KernelCheckpoint,
824    proof: &CheckpointConsistencyProof,
825) -> Result<bool, CheckpointError> {
826    verify_checkpoint_consistency_proof_with_anchor(
827        previous,
828        current,
829        proof,
830        CheckpointConsistencyAnchor::Genesis,
831    )
832}
833
834/// Verify a consistency proof against two signed checkpoints and an anchor for
835/// the prefix below the earlier endpoint.
836///
837/// The Merkle path in the proof is checked against the `chain_root`
838/// commitments inside the two signed bodies, so a verifier needs nothing
839/// beyond the two checkpoints, the proof, and the anchor. Structural
840/// mismatches (wrong pair, missing chain commitments, unsupported schema, an
841/// unanchored mid-chain pair) are errors; a well-formed proof that does not
842/// verify returns `Ok(false)`. Legacy v1 records make no Merkle claim over the
843/// chain, so the anchor does not apply to them.
844pub fn verify_checkpoint_consistency_proof_with_anchor(
845    previous: &KernelCheckpoint,
846    current: &KernelCheckpoint,
847    proof: &CheckpointConsistencyProof,
848    anchor: CheckpointConsistencyAnchor<'_>,
849) -> Result<bool, CheckpointError> {
850    validate_checkpoint_predecessor(previous, current)?;
851    let previous_log_id = checkpoint_log_id(previous);
852    let current_log_id = checkpoint_log_id(current);
853    if previous_log_id != current_log_id {
854        return Err(CheckpointError::Continuity(format!(
855            "checkpoint {} derives log_id {} but predecessor {} derives {}",
856            current.body.checkpoint_seq,
857            current_log_id,
858            previous.body.checkpoint_seq,
859            previous_log_id
860        )));
861    }
862    let metadata_matches = proof.log_id == current_log_id
863        && proof.from_checkpoint_seq == previous.body.checkpoint_seq
864        && proof.to_checkpoint_seq == current.body.checkpoint_seq
865        && proof.from_checkpoint_sha256 == checkpoint_body_sha256(&previous.body)?
866        && proof.to_checkpoint_sha256 == checkpoint_body_sha256(&current.body)?
867        && proof.from_log_tree_size == checkpoint_log_tree_size(&previous.body)
868        && proof.to_log_tree_size == checkpoint_log_tree_size(&current.body)
869        && proof.appended_entry_start_seq == current.body.batch_start_seq
870        && proof.appended_entry_end_seq == current.body.batch_end_seq;
871    if !metadata_matches {
872        return Ok(false);
873    }
874
875    if proof.schema == CHECKPOINT_CONSISTENCY_PROOF_SCHEMA_V1 {
876        if previous.body.schema != CHECKPOINT_SCHEMA_V1
877            || current.body.schema != CHECKPOINT_SCHEMA_V1
878        {
879            return Err(CheckpointError::Invalid(
880                "legacy v1 consistency records apply only to v1 checkpoints".to_string(),
881            ));
882        }
883        return Ok(proof.from_chain_root.is_none()
884            && proof.to_chain_root.is_none()
885            && proof.chain_proof_hashes.is_empty()
886            && proof.from_leaf_inclusion.is_none()
887            && proof.to_leaf_inclusion.is_none());
888    }
889    if proof.schema != CHECKPOINT_CONSISTENCY_PROOF_SCHEMA_V2 {
890        return Err(CheckpointError::Invalid(format!(
891            "unsupported consistency proof schema {}",
892            proof.schema
893        )));
894    }
895    let from_chain_root = require_chain_root(previous)?;
896    let to_chain_root = require_chain_root(current)?;
897    if proof.from_chain_root != Some(from_chain_root) || proof.to_chain_root != Some(to_chain_root)
898    {
899        return Ok(false);
900    }
901    let (Some(from_leaf_inclusion), Some(to_leaf_inclusion)) = (
902        proof.from_leaf_inclusion.as_ref(),
903        proof.to_leaf_inclusion.as_ref(),
904    ) else {
905        return Ok(false);
906    };
907
908    // Both committed chains must end in their own checkpoint's leaf. Binding
909    // only the later endpoint would leave a pair starting after checkpoint 1
910    // open: a key holder could commit an arbitrary tree as the earlier root,
911    // extend it with the later real leaf, and produce paths that verify while
912    // the earlier root never contained the earlier body.
913    let from_size = chain_tree_size(previous)?;
914    let to_size = chain_tree_size(current)?;
915    if !chain_leaf_is_committed(
916        from_leaf_inclusion,
917        from_size,
918        checkpoint_chain_leaf_hash(&previous.body)?,
919        &from_chain_root,
920    ) || !chain_leaf_is_committed(
921        to_leaf_inclusion,
922        to_size,
923        checkpoint_chain_leaf_hash(&current.body)?,
924        &to_chain_root,
925    ) {
926        return Ok(false);
927    }
928
929    if !consistency_prefix_is_anchored(previous, from_size, &from_chain_root, anchor)? {
930        return Ok(false);
931    }
932
933    Ok(verify_consistency_proof(
934        from_size,
935        to_size,
936        &from_chain_root,
937        &to_chain_root,
938        &proof.chain_proof_hashes,
939    ))
940}
941
942#[allow(clippy::too_many_arguments)]
943fn ordered_equivocation(
944    kind: CheckpointEquivocationKind,
945    log_id: Option<String>,
946    log_tree_size: Option<u64>,
947    first_seq: u64,
948    first_sha256: String,
949    second_seq: u64,
950    second_sha256: String,
951    previous_checkpoint_sha256: Option<String>,
952) -> CheckpointEquivocation {
953    if (first_seq, first_sha256.as_str()) <= (second_seq, second_sha256.as_str()) {
954        CheckpointEquivocation {
955            schema: CHECKPOINT_EQUIVOCATION_SCHEMA.to_string(),
956            kind,
957            log_id,
958            log_tree_size,
959            first_checkpoint_seq: first_seq,
960            second_checkpoint_seq: second_seq,
961            first_checkpoint_sha256: first_sha256,
962            second_checkpoint_sha256: second_sha256,
963            previous_checkpoint_sha256,
964        }
965    } else {
966        CheckpointEquivocation {
967            schema: CHECKPOINT_EQUIVOCATION_SCHEMA.to_string(),
968            kind,
969            log_id,
970            log_tree_size,
971            first_checkpoint_seq: second_seq,
972            second_checkpoint_seq: first_seq,
973            first_checkpoint_sha256: second_sha256,
974            second_checkpoint_sha256: first_sha256,
975            previous_checkpoint_sha256,
976        }
977    }
978}
979
980#[derive(Debug)]
981struct ValidatedCheckpoint<'a> {
982    checkpoint: &'a KernelCheckpoint,
983    checkpoint_sha256: String,
984    log_id: String,
985    log_tree_size: u64,
986}
987
988impl<'a> ValidatedCheckpoint<'a> {
989    fn validate(checkpoint: &'a KernelCheckpoint) -> Result<Self, CheckpointError> {
990        validate_checkpoint(checkpoint)?;
991        Self::after_validation(checkpoint)
992    }
993
994    fn after_validation(checkpoint: &'a KernelCheckpoint) -> Result<Self, CheckpointError> {
995        Ok(Self {
996            checkpoint,
997            checkpoint_sha256: checkpoint_body_sha256(&checkpoint.body)?,
998            log_id: checkpoint_log_id(checkpoint),
999            log_tree_size: checkpoint_log_tree_size(&checkpoint.body),
1000        })
1001    }
1002}
1003
1004fn detect_checkpoint_equivocation_from_validated(
1005    first: &ValidatedCheckpoint<'_>,
1006    second: &ValidatedCheckpoint<'_>,
1007) -> Option<CheckpointEquivocation> {
1008    #[cfg(test)]
1009    CHECKPOINT_EQUIVOCATION_INSPECTION_COUNT.with(|count| {
1010        count.set(count.get().saturating_add(1));
1011    });
1012    if first.checkpoint_sha256 == second.checkpoint_sha256 {
1013        return None;
1014    }
1015
1016    if first.checkpoint.body.checkpoint_seq == second.checkpoint.body.checkpoint_seq {
1017        return Some(ordered_equivocation(
1018            CheckpointEquivocationKind::ConflictingCheckpointSeq,
1019            (first.log_id == second.log_id).then(|| first.log_id.clone()),
1020            (first.log_tree_size == second.log_tree_size).then_some(first.log_tree_size),
1021            first.checkpoint.body.checkpoint_seq,
1022            first.checkpoint_sha256.clone(),
1023            second.checkpoint.body.checkpoint_seq,
1024            second.checkpoint_sha256.clone(),
1025            first
1026                .checkpoint
1027                .body
1028                .previous_checkpoint_sha256
1029                .clone()
1030                .or_else(|| second.checkpoint.body.previous_checkpoint_sha256.clone()),
1031        ));
1032    }
1033
1034    if first.log_id == second.log_id && first.log_tree_size == second.log_tree_size {
1035        return Some(ordered_equivocation(
1036            CheckpointEquivocationKind::ConflictingLogTreeSize,
1037            Some(first.log_id.clone()),
1038            Some(first.log_tree_size),
1039            first.checkpoint.body.checkpoint_seq,
1040            first.checkpoint_sha256.clone(),
1041            second.checkpoint.body.checkpoint_seq,
1042            second.checkpoint_sha256.clone(),
1043            first
1044                .checkpoint
1045                .body
1046                .previous_checkpoint_sha256
1047                .clone()
1048                .or_else(|| second.checkpoint.body.previous_checkpoint_sha256.clone()),
1049        ));
1050    }
1051
1052    if first.checkpoint.body.previous_checkpoint_sha256.is_some()
1053        && first.checkpoint.body.previous_checkpoint_sha256
1054            == second.checkpoint.body.previous_checkpoint_sha256
1055    {
1056        return Some(ordered_equivocation(
1057            CheckpointEquivocationKind::ConflictingPredecessorWitness,
1058            (first.log_id == second.log_id).then(|| first.log_id.clone()),
1059            None,
1060            first.checkpoint.body.checkpoint_seq,
1061            first.checkpoint_sha256.clone(),
1062            second.checkpoint.body.checkpoint_seq,
1063            second.checkpoint_sha256.clone(),
1064            first.checkpoint.body.previous_checkpoint_sha256.clone(),
1065        ));
1066    }
1067
1068    None
1069}
1070
1071/// Detect whether two checkpoints conflict under Chio transparency semantics.
1072pub fn detect_checkpoint_equivocation(
1073    first: &KernelCheckpoint,
1074    second: &KernelCheckpoint,
1075) -> Result<Option<CheckpointEquivocation>, CheckpointError> {
1076    validate_checkpoint(first)?;
1077    validate_checkpoint(second)?;
1078    let first = ValidatedCheckpoint::after_validation(first)?;
1079    let second = ValidatedCheckpoint::after_validation(second)?;
1080    Ok(detect_checkpoint_equivocation_from_validated(
1081        &first, &second,
1082    ))
1083}
1084
1085/// Render a checkpoint conflict as a stable, human-readable description.
1086#[must_use]
1087pub fn describe_checkpoint_equivocation(equivocation: &CheckpointEquivocation) -> String {
1088    match equivocation.kind {
1089        CheckpointEquivocationKind::ConflictingCheckpointSeq => format!(
1090            "checkpoint_seq {} has conflicting digests {} and {}",
1091            equivocation.first_checkpoint_seq,
1092            equivocation.first_checkpoint_sha256,
1093            equivocation.second_checkpoint_sha256
1094        ),
1095        CheckpointEquivocationKind::ConflictingLogTreeSize => format!(
1096            "log {} has conflicting checkpoints at cumulative tree size {}: {} ({}) vs {} ({})",
1097            equivocation.log_id.as_deref().unwrap_or("<unknown>"),
1098            equivocation.log_tree_size.unwrap_or_default(),
1099            equivocation.first_checkpoint_seq,
1100            equivocation.first_checkpoint_sha256,
1101            equivocation.second_checkpoint_seq,
1102            equivocation.second_checkpoint_sha256
1103        ),
1104        CheckpointEquivocationKind::ConflictingPredecessorWitness => format!(
1105            "predecessor digest {} is witnessed by conflicting checkpoints {} ({}) and {} ({})",
1106            equivocation
1107                .previous_checkpoint_sha256
1108                .as_deref()
1109                .unwrap_or("<missing>"),
1110            equivocation.first_checkpoint_seq,
1111            equivocation.first_checkpoint_sha256,
1112            equivocation.second_checkpoint_seq,
1113            equivocation.second_checkpoint_sha256
1114        ),
1115    }
1116}
1117
1118fn checkpoint_publication_from_validated(
1119    checkpoint: &ValidatedCheckpoint<'_>,
1120) -> CheckpointPublication {
1121    CheckpointPublication {
1122        log_id: checkpoint.log_id.clone(),
1123        schema: CHECKPOINT_PUBLICATION_SCHEMA.to_string(),
1124        checkpoint_seq: checkpoint.checkpoint.body.checkpoint_seq,
1125        checkpoint_sha256: checkpoint.checkpoint_sha256.clone(),
1126        merkle_root: checkpoint.checkpoint.body.merkle_root,
1127        published_at: checkpoint.checkpoint.body.issued_at,
1128        kernel_key: checkpoint.checkpoint.body.kernel_key.clone(),
1129        log_tree_size: checkpoint.log_tree_size,
1130        entry_start_seq: checkpoint.checkpoint.body.batch_start_seq,
1131        entry_end_seq: checkpoint.checkpoint.body.batch_end_seq,
1132        previous_checkpoint_sha256: checkpoint
1133            .checkpoint
1134            .body
1135            .previous_checkpoint_sha256
1136            .clone(),
1137        trust_anchor_binding: None,
1138    }
1139}
1140
1141fn checkpoint_witness_from_validated(
1142    checkpoint: &ValidatedCheckpoint<'_>,
1143    witness_checkpoint: &ValidatedCheckpoint<'_>,
1144) -> CheckpointWitness {
1145    CheckpointWitness {
1146        log_id: checkpoint.log_id.clone(),
1147        schema: CHECKPOINT_WITNESS_SCHEMA.to_string(),
1148        checkpoint_seq: checkpoint.checkpoint.body.checkpoint_seq,
1149        checkpoint_sha256: checkpoint.checkpoint_sha256.clone(),
1150        witness_checkpoint_seq: witness_checkpoint.checkpoint.body.checkpoint_seq,
1151        witness_checkpoint_sha256: witness_checkpoint.checkpoint_sha256.clone(),
1152        witnessed_at: witness_checkpoint.checkpoint.body.issued_at,
1153    }
1154}
1155
1156#[derive(Default)]
1157struct EquivocationKeyBucket {
1158    first_digest_by_position: BTreeMap<usize, String>,
1159    last_position_by_digest: BTreeMap<String, usize>,
1160}
1161
1162fn index_equivocation_key<K: Ord>(
1163    index: &mut BTreeMap<K, EquivocationKeyBucket>,
1164    key: K,
1165    checkpoint_sha256: &str,
1166    position: usize,
1167    candidate_pairs: &mut BTreeSet<(String, String)>,
1168) {
1169    let bucket = index.entry(key).or_default();
1170    match bucket
1171        .last_position_by_digest
1172        .get(checkpoint_sha256)
1173        .copied()
1174    {
1175        None => {
1176            candidate_pairs.extend(
1177                bucket
1178                    .last_position_by_digest
1179                    .keys()
1180                    .map(|digest| (digest.clone(), checkpoint_sha256.to_string())),
1181            );
1182            bucket
1183                .first_digest_by_position
1184                .insert(position, checkpoint_sha256.to_string());
1185        }
1186        Some(previous_position) => {
1187            candidate_pairs.extend(
1188                bucket
1189                    .first_digest_by_position
1190                    .range((
1191                        std::ops::Bound::Excluded(previous_position),
1192                        std::ops::Bound::Excluded(position),
1193                    ))
1194                    .map(|(_, digest)| (digest.clone(), checkpoint_sha256.to_string())),
1195            );
1196        }
1197    }
1198    bucket
1199        .last_position_by_digest
1200        .insert(checkpoint_sha256.to_string(), position);
1201}
1202
1203struct DerivedCheckpointTransparency<'a> {
1204    summary: CheckpointTransparencySummary,
1205    checkpoints: Vec<ValidatedCheckpoint<'a>>,
1206}
1207
1208fn derive_checkpoint_transparency(
1209    checkpoints: &[KernelCheckpoint],
1210) -> Result<DerivedCheckpointTransparency<'_>, CheckpointError> {
1211    let checkpoints = checkpoints
1212        .iter()
1213        .map(ValidatedCheckpoint::validate)
1214        .collect::<Result<Vec<_>, _>>()?;
1215    let mut publications = checkpoints
1216        .iter()
1217        .map(checkpoint_publication_from_validated)
1218        .collect::<Vec<_>>();
1219    publications.sort_by_key(|publication| publication.checkpoint_seq);
1220
1221    // A clean prefix has no candidate pairs. Work grows with the number of
1222    // indexed key collisions and emitted conflicts rather than every possible
1223    // pair of checkpoints.
1224    let mut by_checkpoint_seq = BTreeMap::<u64, EquivocationKeyBucket>::new();
1225    let mut by_log_tree_size = BTreeMap::<(String, u64), EquivocationKeyBucket>::new();
1226    let mut by_predecessor_digest = BTreeMap::<String, EquivocationKeyBucket>::new();
1227    let mut candidate_pairs = BTreeSet::<(String, String)>::new();
1228    for (position, checkpoint) in checkpoints.iter().enumerate() {
1229        index_equivocation_key(
1230            &mut by_checkpoint_seq,
1231            checkpoint.checkpoint.body.checkpoint_seq,
1232            &checkpoint.checkpoint_sha256,
1233            position,
1234            &mut candidate_pairs,
1235        );
1236        index_equivocation_key(
1237            &mut by_log_tree_size,
1238            (checkpoint.log_id.clone(), checkpoint.log_tree_size),
1239            &checkpoint.checkpoint_sha256,
1240            position,
1241            &mut candidate_pairs,
1242        );
1243        if let Some(previous_checkpoint_sha256) = checkpoint
1244            .checkpoint
1245            .body
1246            .previous_checkpoint_sha256
1247            .clone()
1248        {
1249            index_equivocation_key(
1250                &mut by_predecessor_digest,
1251                previous_checkpoint_sha256,
1252                &checkpoint.checkpoint_sha256,
1253                position,
1254                &mut candidate_pairs,
1255            );
1256        }
1257    }
1258
1259    let by_digest = checkpoints
1260        .iter()
1261        .enumerate()
1262        .map(|(position, checkpoint)| (checkpoint.checkpoint_sha256.clone(), position))
1263        .collect::<BTreeMap<_, _>>();
1264    let mut equivocations = candidate_pairs
1265        .into_iter()
1266        .map(|(first_digest, second_digest)| {
1267            let first = by_digest.get(&first_digest).copied().ok_or_else(|| {
1268                CheckpointError::Invalid(
1269                    "equivocation index references a missing first checkpoint digest".to_string(),
1270                )
1271            })?;
1272            let second = by_digest.get(&second_digest).copied().ok_or_else(|| {
1273                CheckpointError::Invalid(
1274                    "equivocation index references a missing second checkpoint digest".to_string(),
1275                )
1276            })?;
1277            Ok(detect_checkpoint_equivocation_from_validated(
1278                &checkpoints[first],
1279                &checkpoints[second],
1280            ))
1281        })
1282        .collect::<Result<Vec<_>, CheckpointError>>()?
1283        .into_iter()
1284        .flatten()
1285        .collect::<Vec<_>>();
1286    equivocations.sort();
1287    equivocations.dedup();
1288    let equivocated_digests = equivocations
1289        .iter()
1290        .flat_map(|equivocation| {
1291            [
1292                equivocation.first_checkpoint_sha256.clone(),
1293                equivocation.second_checkpoint_sha256.clone(),
1294            ]
1295        })
1296        .collect::<BTreeSet<_>>();
1297
1298    // The signed chain commitment is global across checkpoint signing-key
1299    // rotation. Derive its leaves from the contiguous, unique sequence run
1300    // beginning at 1, then restrict proof endpoints to a common log identity.
1301    // This preserves proofs between checkpoints signed by the post-rotation
1302    // key without incorrectly requiring that key to have issued sequence 1.
1303    let mut by_seq = BTreeMap::<u64, Vec<usize>>::new();
1304    for (position, checkpoint) in checkpoints.iter().enumerate() {
1305        by_seq
1306            .entry(checkpoint.checkpoint.body.checkpoint_seq)
1307            .or_default()
1308            .push(position);
1309    }
1310    let mut chain_leaf_hashes = Vec::new();
1311    let mut next_seq = 1u64;
1312    while let Some([single]) = by_seq.get(&next_seq).map(Vec::as_slice) {
1313        chain_leaf_hashes.push(checkpoint_chain_leaf_hash(
1314            &checkpoints[*single].checkpoint.body,
1315        )?);
1316        let Some(following) = next_seq.checked_add(1) else {
1317            break;
1318        };
1319        next_seq = following;
1320    }
1321    let chain_tree = (!chain_leaf_hashes.is_empty())
1322        .then(|| MerkleTree::from_hashes(chain_leaf_hashes.clone()))
1323        .transpose()?;
1324
1325    let mut witnesses = Vec::new();
1326    let mut consistency_proofs = Vec::new();
1327    for checkpoint in &checkpoints {
1328        let Some(previous_checkpoint_sha256) = checkpoint
1329            .checkpoint
1330            .body
1331            .previous_checkpoint_sha256
1332            .as_deref()
1333        else {
1334            continue;
1335        };
1336        if let Some(previous_position) = by_digest.get(previous_checkpoint_sha256) {
1337            let previous = &checkpoints[*previous_position];
1338            if let Err(error) = validate_checkpoint_predecessor_link(
1339                previous.checkpoint,
1340                &previous.checkpoint_sha256,
1341                checkpoint.checkpoint,
1342            ) {
1343                if equivocated_digests.contains(&checkpoint.checkpoint_sha256) {
1344                    continue;
1345                }
1346                return Err(error);
1347            }
1348            witnesses.push(checkpoint_witness_from_validated(previous, checkpoint));
1349            let to_size = chain_tree_size(checkpoint.checkpoint)?;
1350            if let Some(tree) = chain_tree.as_ref() {
1351                if chain_leaf_hashes.len() >= to_size {
1352                    // The checkpoint-chain commitment is global. Verify both
1353                    // signed roots against the retained prefix even when a
1354                    // signing-key rotation changes the derived log identity.
1355                    validate_checkpoint_chain_commitments(
1356                        previous.checkpoint,
1357                        checkpoint.checkpoint,
1358                        &chain_leaf_hashes,
1359                        tree,
1360                    )?;
1361                    if previous.log_id == checkpoint.log_id {
1362                        if previous.checkpoint.body.schema == CHECKPOINT_SCHEMA_V1
1363                            && checkpoint.checkpoint.body.schema == CHECKPOINT_SCHEMA_V1
1364                        {
1365                            consistency_proofs.push(
1366                                build_legacy_checkpoint_consistency_record_from_validated(
1367                                    previous, checkpoint,
1368                                )?,
1369                            );
1370                        } else if previous.checkpoint.body.chain_root.is_some()
1371                            && checkpoint.checkpoint.body.chain_root.is_some()
1372                        {
1373                            consistency_proofs.push(
1374                                build_checkpoint_consistency_proof_from_validated(
1375                                    previous,
1376                                    checkpoint,
1377                                    &chain_leaf_hashes,
1378                                    tree,
1379                                )?,
1380                            );
1381                        }
1382                    }
1383                }
1384            }
1385        }
1386    }
1387    witnesses.sort_by_key(|witness| (witness.witness_checkpoint_seq, witness.checkpoint_seq));
1388    consistency_proofs.sort_by_key(|proof| (proof.to_checkpoint_seq, proof.from_checkpoint_seq));
1389
1390    Ok(DerivedCheckpointTransparency {
1391        summary: CheckpointTransparencySummary {
1392            publications,
1393            witnesses,
1394            consistency_proofs,
1395            equivocations,
1396        },
1397        checkpoints,
1398    })
1399}
1400
1401/// Derive publication, witness, and equivocation records from a checkpoint set.
1402pub fn build_checkpoint_transparency(
1403    checkpoints: &[KernelCheckpoint],
1404) -> Result<CheckpointTransparencySummary, CheckpointError> {
1405    Ok(derive_checkpoint_transparency(checkpoints)?.summary)
1406}
1407
1408/// Validate that a checkpoint set is transparency-safe, fork-free, and
1409/// connected to checkpoint 1.
1410///
1411/// A caller that wants to trust a later boundary without the full prefix must
1412/// use a separate API that accepts an explicitly pinned boundary. This
1413/// verifier has no such input, so an unresolved predecessor fails closed.
1414pub fn validate_checkpoint_transparency(
1415    checkpoints: &[KernelCheckpoint],
1416) -> Result<CheckpointTransparencySummary, CheckpointError> {
1417    let mut checkpoint_seqs = BTreeSet::new();
1418    for checkpoint in checkpoints {
1419        if !checkpoint_seqs.insert(checkpoint.body.checkpoint_seq) {
1420            return Err(CheckpointError::Continuity(format!(
1421                "duplicate checkpoint sequence {}",
1422                checkpoint.body.checkpoint_seq
1423            )));
1424        }
1425    }
1426
1427    let derived = derive_checkpoint_transparency(checkpoints)?;
1428    let transparency = derived.summary;
1429    if let Some(equivocation) = transparency.equivocations.first() {
1430        return Err(CheckpointError::Continuity(format!(
1431            "checkpoint equivocation detected: {}",
1432            describe_checkpoint_equivocation(equivocation)
1433        )));
1434    }
1435
1436    let by_digest = derived
1437        .checkpoints
1438        .iter()
1439        .enumerate()
1440        .map(|(position, checkpoint)| (checkpoint.checkpoint_sha256.as_str(), position))
1441        .collect::<BTreeMap<_, _>>();
1442    for checkpoint in &derived.checkpoints {
1443        let Some(previous_checkpoint_sha256) = checkpoint
1444            .checkpoint
1445            .body
1446            .previous_checkpoint_sha256
1447            .as_deref()
1448        else {
1449            if checkpoint.checkpoint.body.checkpoint_seq != 1 {
1450                return Err(CheckpointError::Continuity(format!(
1451                    "checkpoint {} is not connected to checkpoint 1",
1452                    checkpoint.checkpoint.body.checkpoint_seq
1453                )));
1454            }
1455            if checkpoint.checkpoint.body.batch_start_seq != 1 {
1456                return Err(CheckpointError::Continuity(format!(
1457                    "checkpoint 1 must start at receipt 1, got {}",
1458                    checkpoint.checkpoint.body.batch_start_seq
1459                )));
1460            }
1461            continue;
1462        };
1463        let previous = by_digest.get(previous_checkpoint_sha256).ok_or_else(|| {
1464            CheckpointError::Continuity(format!(
1465                "checkpoint {} has unresolved predecessor {}",
1466                checkpoint.checkpoint.body.checkpoint_seq, previous_checkpoint_sha256
1467            ))
1468        })?;
1469        let previous = &derived.checkpoints[*previous];
1470        validate_checkpoint_predecessor_link(
1471            previous.checkpoint,
1472            &previous.checkpoint_sha256,
1473            checkpoint.checkpoint,
1474        )?;
1475    }
1476
1477    Ok(transparency)
1478}
1479
1480/// Verify that supplied transparency records match the signed checkpoint set.
1481///
1482/// Valid trust-anchor bindings are preserved in the returned summary so callers
1483/// can safely project publication state without collapsing back to raw
1484/// checkpoint-only records.
1485pub fn verify_checkpoint_transparency_records(
1486    checkpoints: &[KernelCheckpoint],
1487    supplied: &CheckpointTransparencySummary,
1488) -> Result<CheckpointTransparencySummary, CheckpointError> {
1489    let derived = validate_checkpoint_transparency(checkpoints)?;
1490    let derived_publications = derived
1491        .publications
1492        .iter()
1493        .map(|publication| (publication.checkpoint_seq, publication))
1494        .collect::<BTreeMap<_, _>>();
1495
1496    if supplied.publications.len() != derived.publications.len() {
1497        return Err(CheckpointError::Continuity(
1498            "checkpoint publication records do not match the signed checkpoint set".to_string(),
1499        ));
1500    }
1501
1502    let mut normalized_publications = Vec::with_capacity(supplied.publications.len());
1503    let mut matched_checkpoint_seqs = BTreeSet::new();
1504    for publication in &supplied.publications {
1505        if !matched_checkpoint_seqs.insert(publication.checkpoint_seq) {
1506            return Err(CheckpointError::Continuity(format!(
1507                "duplicate checkpoint publication record for checkpoint {}",
1508                publication.checkpoint_seq
1509            )));
1510        }
1511        let Some(derived_publication) = derived_publications
1512            .get(&publication.checkpoint_seq)
1513            .copied()
1514        else {
1515            return Err(CheckpointError::Continuity(
1516                "checkpoint publication records do not match the signed checkpoint set".to_string(),
1517            ));
1518        };
1519        let expected = match publication.trust_anchor_binding.clone() {
1520            Some(binding) => {
1521                bind_checkpoint_publication_trust_anchor((*derived_publication).clone(), binding)?
1522            }
1523            None => (*derived_publication).clone(),
1524        };
1525        if publication != &expected {
1526            return Err(CheckpointError::Continuity(
1527                "checkpoint publication records do not match the signed checkpoint set".to_string(),
1528            ));
1529        }
1530        normalized_publications.push(expected);
1531    }
1532    if matched_checkpoint_seqs.len() != derived_publications.len() {
1533        return Err(CheckpointError::Continuity(
1534            "checkpoint publication records do not cover the signed checkpoint set".to_string(),
1535        ));
1536    }
1537
1538    if supplied.witnesses != derived.witnesses {
1539        return Err(CheckpointError::Continuity(
1540            "checkpoint witness records do not match the signed checkpoint set".to_string(),
1541        ));
1542    }
1543    if supplied.consistency_proofs != derived.consistency_proofs {
1544        return Err(CheckpointError::Continuity(
1545            "checkpoint consistency proof records do not match the signed checkpoint set"
1546                .to_string(),
1547        ));
1548    }
1549    if supplied.equivocations != derived.equivocations {
1550        return Err(CheckpointError::Continuity(
1551            "checkpoint equivocation records do not match the signed checkpoint set".to_string(),
1552        ));
1553    }
1554
1555    Ok(CheckpointTransparencySummary {
1556        publications: normalized_publications,
1557        witnesses: supplied.witnesses.clone(),
1558        consistency_proofs: supplied.consistency_proofs.clone(),
1559        equivocations: supplied.equivocations.clone(),
1560    })
1561}
1562
1563/// Verify that `current` explicitly extends `previous`.
1564pub fn verify_checkpoint_continuity(
1565    previous: &KernelCheckpoint,
1566    current: &KernelCheckpoint,
1567) -> Result<bool, CheckpointError> {
1568    match validate_checkpoint_predecessor(previous, current) {
1569        Ok(()) => Ok(true),
1570        Err(CheckpointError::Continuity(_)) => Ok(false),
1571        Err(error) => Err(error),
1572    }
1573}
1574
1575/// Return the current Unix timestamp in seconds.
1576fn unix_now() -> u64 {
1577    SystemTime::now()
1578        .duration_since(UNIX_EPOCH)
1579        .map(|d| d.as_secs())
1580        .unwrap_or(0)
1581}
1582
1583/// Build a signed kernel checkpoint from a batch of canonical receipt bytes.
1584///
1585/// `receipt_canonical_bytes_batch` must not be empty. The first checkpoint of
1586/// a chain (`checkpoint_seq == 1`) commits a single-leaf chain; a detached
1587/// checkpoint at a later sequence is issued without a chain commitment.
1588pub fn build_checkpoint(
1589    checkpoint_seq: u64,
1590    batch_start_seq: u64,
1591    batch_end_seq: u64,
1592    receipt_canonical_bytes_batch: &[Vec<u8>],
1593    keypair: &Keypair,
1594) -> Result<KernelCheckpoint, CheckpointError> {
1595    build_checkpoint_with_previous(
1596        checkpoint_seq,
1597        batch_start_seq,
1598        batch_end_seq,
1599        receipt_canonical_bytes_batch,
1600        keypair,
1601        None,
1602        &[],
1603    )
1604}
1605
1606/// Build a signed kernel checkpoint that explicitly links to the previous
1607/// checkpoint when provided.
1608///
1609/// `prior_chain_leaf_hashes` must hold the chain leaf of every prior
1610/// checkpoint in sequence order (see [`checkpoint_chain_leaf_hash`]); the new
1611/// body then carries a `chain_root` extending them, and the leaves are
1612/// cross-checked against the predecessor's own commitment when it has one. An
1613/// empty slice is valid only with no previous checkpoint.
1614pub fn build_checkpoint_with_previous(
1615    checkpoint_seq: u64,
1616    batch_start_seq: u64,
1617    batch_end_seq: u64,
1618    receipt_canonical_bytes_batch: &[Vec<u8>],
1619    keypair: &Keypair,
1620    previous_checkpoint: Option<&KernelCheckpoint>,
1621    prior_chain_leaf_hashes: &[Hash],
1622) -> Result<KernelCheckpoint, CheckpointError> {
1623    build_checkpoint_with_chain_frontier(
1624        checkpoint_seq,
1625        batch_start_seq,
1626        batch_end_seq,
1627        receipt_canonical_bytes_batch,
1628        keypair,
1629        previous_checkpoint,
1630        &CheckpointChainFrontier::from_leaves(prior_chain_leaf_hashes),
1631    )
1632}
1633
1634/// Build a signed kernel checkpoint from the chain frontier rather than from
1635/// every prior leaf.
1636///
1637/// This is the hot path: a long-lived writer keeps the frontier and extends
1638/// it, so issuing a checkpoint costs O(log n) hashes instead of rehashing the
1639/// whole chain. The predecessor's signed `chain_root` is still checked, at the
1640/// same O(log n) cost, so the integrity guarantee is unchanged.
1641#[allow(clippy::too_many_arguments)]
1642pub fn build_checkpoint_with_chain_frontier(
1643    checkpoint_seq: u64,
1644    batch_start_seq: u64,
1645    batch_end_seq: u64,
1646    receipt_canonical_bytes_batch: &[Vec<u8>],
1647    keypair: &Keypair,
1648    previous_checkpoint: Option<&KernelCheckpoint>,
1649    prior_chain: &CheckpointChainFrontier,
1650) -> Result<KernelCheckpoint, CheckpointError> {
1651    let tree = MerkleTree::from_leaves(receipt_canonical_bytes_batch)?;
1652    let merkle_root = tree.root();
1653    let covered_entries = batch_end_seq
1654        .checked_sub(batch_start_seq)
1655        .and_then(|count| count.checked_add(1))
1656        .ok_or_else(|| {
1657            CheckpointError::Invalid(format!(
1658                "invalid checkpoint entry range {batch_start_seq}-{batch_end_seq}"
1659            ))
1660        })?;
1661    if usize::try_from(covered_entries).ok() != Some(tree.leaf_count()) {
1662        return Err(CheckpointError::Invalid(format!(
1663            "receipt batch length {} does not match covered entry count {} for range {}-{}",
1664            tree.leaf_count(),
1665            covered_entries,
1666            batch_start_seq,
1667            batch_end_seq
1668        )));
1669    }
1670
1671    let own_chain_leaf = checkpoint_chain_leaf_hash_from_parts(
1672        checkpoint_seq,
1673        batch_start_seq,
1674        batch_end_seq,
1675        merkle_root,
1676    )?;
1677    let chain_root = match previous_checkpoint {
1678        None => {
1679            if prior_chain.leaf_count() != 0 {
1680                return Err(CheckpointError::Invalid(
1681                    "prior chain leaves supplied without a previous checkpoint".to_string(),
1682                ));
1683            }
1684            (checkpoint_seq == 1)
1685                .then(|| checkpoint_chain_root(&[own_chain_leaf]))
1686                .transpose()?
1687        }
1688        Some(previous) => {
1689            validate_checkpoint(previous)?;
1690            validate_checkpoint_successor_position(previous, checkpoint_seq, batch_start_seq)?;
1691            if prior_chain.leaf_count() != previous.body.checkpoint_seq {
1692                return Err(CheckpointError::Continuity(format!(
1693                    "prior chain covers {} leaves but predecessor is checkpoint {}",
1694                    prior_chain.leaf_count(),
1695                    previous.body.checkpoint_seq
1696                )));
1697            }
1698            // When the predecessor committed a chain, the frontier must
1699            // reproduce exactly that commitment: this is what stops a stale or
1700            // foreign frontier from being extended into a signed root.
1701            if let Some(previous_chain_root) = previous.body.chain_root {
1702                if prior_chain.root() != Some(previous_chain_root) {
1703                    return Err(CheckpointError::Continuity(format!(
1704                        "predecessor {} chain_root does not match the supplied chain",
1705                        previous.body.checkpoint_seq
1706                    )));
1707                }
1708            }
1709            let mut chain = prior_chain.clone();
1710            chain.append(own_chain_leaf);
1711            chain.root()
1712        }
1713    };
1714
1715    let body = KernelCheckpointBody {
1716        schema: CHECKPOINT_SCHEMA.to_string(),
1717        checkpoint_seq,
1718        batch_start_seq,
1719        batch_end_seq,
1720        tree_size: tree.leaf_count(),
1721        merkle_root,
1722        issued_at: unix_now(),
1723        kernel_key: keypair.public_key(),
1724        previous_checkpoint_sha256: previous_checkpoint
1725            .map(|checkpoint| checkpoint_body_sha256(&checkpoint.body))
1726            .transpose()?,
1727        chain_root,
1728    };
1729    let body_bytes =
1730        canonical_json_bytes(&body).map_err(|e| CheckpointError::Serialization(e.to_string()))?;
1731    let signature = keypair.sign(&body_bytes);
1732    Ok(KernelCheckpoint { body, signature })
1733}
1734
1735/// Build an inclusion proof for a leaf in an already-built MerkleTree.
1736pub fn build_inclusion_proof(
1737    tree: &MerkleTree,
1738    leaf_index: usize,
1739    checkpoint_seq: u64,
1740    receipt_seq: u64,
1741) -> Result<ReceiptInclusionProof, CheckpointError> {
1742    let proof = tree.inclusion_proof(leaf_index)?;
1743    Ok(ReceiptInclusionProof {
1744        checkpoint_seq,
1745        receipt_seq,
1746        leaf_index,
1747        merkle_root: tree.root(),
1748        proof,
1749    })
1750}
1751
1752/// Verify the signature on a KernelCheckpoint.
1753///
1754/// Returns `Ok(true)` if the signature is valid.
1755pub fn verify_checkpoint_signature(checkpoint: &KernelCheckpoint) -> Result<bool, CheckpointError> {
1756    #[cfg(test)]
1757    CHECKPOINT_SIGNATURE_VERIFICATION_COUNT.with(|count| {
1758        count.set(count.get().saturating_add(1));
1759    });
1760    let body_bytes = canonical_json_bytes(&checkpoint.body)
1761        .map_err(|e| CheckpointError::Serialization(e.to_string()))?;
1762    Ok(checkpoint
1763        .body
1764        .kernel_key
1765        .verify(&body_bytes, &checkpoint.signature))
1766}
1767
1768#[cfg(test)]
1769fn checkpoint_signature_verification_count_for_test() -> usize {
1770    CHECKPOINT_SIGNATURE_VERIFICATION_COUNT.with(std::cell::Cell::get)
1771}
1772
1773#[cfg(test)]
1774fn checkpoint_equivocation_inspection_count_for_test() -> usize {
1775    CHECKPOINT_EQUIVOCATION_INSPECTION_COUNT.with(std::cell::Cell::get)
1776}
1777
1778/// Validate the integrity of a single checkpoint statement.
1779pub fn validate_checkpoint(checkpoint: &KernelCheckpoint) -> Result<(), CheckpointError> {
1780    if !is_supported_checkpoint_schema(&checkpoint.body.schema) {
1781        return Err(CheckpointError::Invalid(format!(
1782            "unsupported checkpoint schema {}",
1783            checkpoint.body.schema
1784        )));
1785    }
1786    if checkpoint.body.checkpoint_seq == 0 {
1787        return Err(CheckpointError::Invalid(
1788            "checkpoint_seq must be greater than zero".to_string(),
1789        ));
1790    }
1791    if checkpoint.body.batch_start_seq == 0 {
1792        return Err(CheckpointError::Invalid(
1793            "batch_start_seq must be greater than zero".to_string(),
1794        ));
1795    }
1796    if checkpoint.body.batch_end_seq < checkpoint.body.batch_start_seq {
1797        return Err(CheckpointError::Invalid(format!(
1798            "batch_end_seq {} is less than batch_start_seq {}",
1799            checkpoint.body.batch_end_seq, checkpoint.body.batch_start_seq
1800        )));
1801    }
1802    if checkpoint.body.tree_size == 0 {
1803        return Err(CheckpointError::Invalid(
1804            "tree_size must be greater than zero".to_string(),
1805        ));
1806    }
1807    if checkpoint.body.issued_at == 0 {
1808        return Err(CheckpointError::Invalid(
1809            "issued_at must be greater than zero".to_string(),
1810        ));
1811    }
1812    if checkpoint
1813        .body
1814        .previous_checkpoint_sha256
1815        .as_deref()
1816        .is_some_and(|digest| !is_lowercase_sha256(digest))
1817    {
1818        return Err(CheckpointError::Invalid(
1819            "previous_checkpoint_sha256 must be 64 lowercase hex characters".to_string(),
1820        ));
1821    }
1822    let expected_tree_size = checkpoint_batch_entry_count(&checkpoint.body)?;
1823    if u64::try_from(checkpoint.body.tree_size).ok() != Some(expected_tree_size) {
1824        return Err(CheckpointError::Invalid(format!(
1825            "tree_size {} does not match covered entry count {} for range {}-{}",
1826            checkpoint.body.tree_size,
1827            expected_tree_size,
1828            checkpoint.body.batch_start_seq,
1829            checkpoint.body.batch_end_seq
1830        )));
1831    }
1832    if checkpoint.body.schema == CHECKPOINT_SCHEMA_V1 && checkpoint.body.chain_root.is_some() {
1833        return Err(CheckpointError::Invalid(
1834            "v1 checkpoint statements cannot carry chain_root".to_string(),
1835        ));
1836    }
1837    if checkpoint.body.schema == CHECKPOINT_SCHEMA_V2
1838        && checkpoint.body.checkpoint_seq == 1
1839        && checkpoint.body.chain_root.is_none()
1840    {
1841        return Err(CheckpointError::Invalid(
1842            "v2 checkpoint 1 must carry chain_root".to_string(),
1843        ));
1844    }
1845    if let Some(chain_root) = checkpoint.body.chain_root {
1846        if checkpoint.body.checkpoint_seq == 1
1847            && chain_root
1848                != checkpoint_chain_root(&[checkpoint_chain_leaf_hash(&checkpoint.body)?])?
1849        {
1850            return Err(CheckpointError::Invalid(
1851                "chain_root of the first checkpoint does not commit its own chain leaf".to_string(),
1852            ));
1853        }
1854    }
1855    if !verify_checkpoint_signature(checkpoint)? {
1856        return Err(CheckpointError::InvalidSignature);
1857    }
1858    Ok(())
1859}
1860
1861fn is_lowercase_sha256(value: &str) -> bool {
1862    value.len() == 64
1863        && value
1864            .bytes()
1865            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1866}
1867
1868fn validate_checkpoint_successor_position(
1869    predecessor: &KernelCheckpoint,
1870    checkpoint_seq: u64,
1871    batch_start_seq: u64,
1872) -> Result<(), CheckpointError> {
1873    let expected_checkpoint_seq =
1874        predecessor
1875            .body
1876            .checkpoint_seq
1877            .checked_add(1)
1878            .ok_or_else(|| {
1879                CheckpointError::Continuity("predecessor checkpoint_seq overflowed u64".to_string())
1880            })?;
1881    if checkpoint_seq != expected_checkpoint_seq {
1882        return Err(CheckpointError::Continuity(format!(
1883            "checkpoint_seq {} does not immediately follow predecessor {}",
1884            checkpoint_seq, predecessor.body.checkpoint_seq
1885        )));
1886    }
1887
1888    let expected_batch_start = predecessor
1889        .body
1890        .batch_end_seq
1891        .checked_add(1)
1892        .ok_or_else(|| {
1893            CheckpointError::Continuity("predecessor batch_end_seq overflowed u64".to_string())
1894        })?;
1895    if batch_start_seq != expected_batch_start {
1896        return Err(CheckpointError::Continuity(format!(
1897            "batch_start_seq {} does not immediately follow predecessor batch_end_seq {}",
1898            batch_start_seq, predecessor.body.batch_end_seq
1899        )));
1900    }
1901
1902    Ok(())
1903}
1904
1905/// Validate that `checkpoint` cleanly extends `predecessor`.
1906pub fn validate_checkpoint_predecessor(
1907    predecessor: &KernelCheckpoint,
1908    checkpoint: &KernelCheckpoint,
1909) -> Result<(), CheckpointError> {
1910    validate_checkpoint(predecessor)?;
1911    validate_checkpoint(checkpoint)?;
1912    let predecessor_sha256 = checkpoint_body_sha256(&predecessor.body)?;
1913    validate_checkpoint_predecessor_link(predecessor, &predecessor_sha256, checkpoint)
1914}
1915
1916fn validate_checkpoint_predecessor_link(
1917    predecessor: &KernelCheckpoint,
1918    predecessor_sha256: &str,
1919    checkpoint: &KernelCheckpoint,
1920) -> Result<(), CheckpointError> {
1921    validate_checkpoint_successor_position(
1922        predecessor,
1923        checkpoint.body.checkpoint_seq,
1924        checkpoint.body.batch_start_seq,
1925    )?;
1926
1927    let Some(previous_checkpoint_sha256) = checkpoint.body.previous_checkpoint_sha256.as_deref()
1928    else {
1929        return Err(CheckpointError::Continuity(format!(
1930            "checkpoint {} is missing predecessor digest",
1931            checkpoint.body.checkpoint_seq
1932        )));
1933    };
1934    if previous_checkpoint_sha256 != predecessor_sha256 {
1935        return Err(CheckpointError::Continuity(format!(
1936            "checkpoint {} does not match predecessor digest {}",
1937            checkpoint.body.checkpoint_seq, predecessor_sha256
1938        )));
1939    }
1940
1941    if predecessor.body.chain_root.is_some() && checkpoint.body.chain_root.is_none() {
1942        return Err(CheckpointError::Continuity(format!(
1943            "checkpoint {} drops the chain commitment its predecessor carries",
1944            checkpoint.body.checkpoint_seq
1945        )));
1946    }
1947    if checkpoint.body.schema == CHECKPOINT_SCHEMA_V2 && checkpoint.body.chain_root.is_none() {
1948        return Err(CheckpointError::Continuity(format!(
1949            "v2 checkpoint {} does not start or extend the chain commitment",
1950            checkpoint.body.checkpoint_seq
1951        )));
1952    }
1953
1954    Ok(())
1955}
1956
1957#[cfg(test)]
1958#[path = "checkpoint/tests.rs"]
1959mod tests;