Skip to main content

acdp_types/
log.rs

1//! Registry transparency log wire types (ACDP 0.3, RFC-ACDP-0012 —
2//! promoted from the RFC-ACDP-0009 §2.11 reservation).
3//!
4//! The log is a per-registry, append-only RFC 6962-style Merkle tree
5//! over publish events: one accepted publish → one [`LogLeaf`], forever.
6//! The registry commits to the tree with signed [`LogCheckpoint`]s
7//! (signed tree heads) and proves membership/history with
8//! [`LogInclusion`] and [`LogConsistencyProof`]. Receipts
9//! (RFC-ACDP-0010/0011) made registry claims *attributable*; the log
10//! makes mint-time backdating, omission-after-the-fact, and
11//! per-consumer equivocation *detectable by any auditor holding
12//! checkpoints* (RFC-ACDP-0012 §3).
13//!
14//! ## Byte-level constructions
15//!
16//! - **Leaf encoding** — the JCS canonicalization (RFC 8785) of the
17//!   closed leaf object; there is no exclusion set (§4).
18//!   `leaf_hash = SHA-256(0x00 ‖ JCS(leaf))` (§5.1).
19//! - **Checkpoint signing** — RFC-ACDP-0010 §5 verbatim (§6): JCS of
20//!   the checkpoint minus `signature`, SHA-256, signature over the
21//!   **ASCII bytes of the `"sha256:<hex>"` string** — with the
22//!   registry's **receipt signing key** (no new key role).
23//! - **Merkle arithmetic** — [`acdp_crypto::merkle`] (RFC 6962 §2.1
24//!   with the `0x00`/`0x01` domain-separation prefixes; verification
25//!   per RFC 9162 §2.1.3.2 / §2.1.4.2 as transcribed in §9).
26//!
27//! All verification failures here map to
28//! [`AcdpError::InvalidLogProof`] — deliberately **not**
29//! `InvalidReceipt`: a proof failure indicts the *log* (tree
30//! membership, history consistency, checkpoint signature), not the
31//! receipt, and the verdicts are independent (RFC-ACDP-0012 §9.3, §11).
32
33use crate::body::Signature;
34use crate::receipt::{
35    is_canonical_ms_utc, ms_rfc3339, preimage_hash_of_object_with, verify_signature_over_hash_with,
36    ReceiptSigner,
37};
38use acdp_jcs::try_canonicalize_value;
39use acdp_primitives::error::AcdpError;
40use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43
44/// The leaf envelope version (RFC-ACDP-0012 §4). In-object domain
45/// separator (the RFC-ACDP-0011 `receipt_version` convention): a leaf
46/// can never be mistaken for an RFC-ACDP-0010 receipt preimage or any
47/// other JCS-canonicalized ACDP object.
48pub const LOG_LEAF_VERSION: &str = "acdp-log-leaf/1";
49
50/// The checkpoint envelope version (RFC-ACDP-0012 §6). In-preimage
51/// domain separator: a checkpoint can never be mistaken for — or
52/// replayed as — a context receipt or head receipt.
53pub const LOG_CHECKPOINT_VERSION: &str = "acdp-log/1";
54
55/// Decode a repository-form `"sha256:<64 lowercase hex>"` string to the
56/// raw 32-byte digest it encodes (RFC-ACDP-0012 §2: Merkle-internal
57/// computation operates on the raw digests the wire strings encode).
58pub fn decode_sha256_hex(prefixed: &str) -> Result<[u8; 32], AcdpError> {
59    let hex_part = prefixed.strip_prefix("sha256:").ok_or_else(|| {
60        AcdpError::InvalidLogProof(format!(
61            "hash '{prefixed}' is not of the form 'sha256:<hex>' (RFC-ACDP-0012 §2)"
62        ))
63    })?;
64    if hex_part.len() != 64
65        || !hex_part
66            .bytes()
67            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
68    {
69        return Err(AcdpError::InvalidLogProof(format!(
70            "hash '{prefixed}' is not 'sha256:' + 64 lowercase hex digits (RFC-ACDP-0012 §2)"
71        )));
72    }
73    let bytes = hex::decode(hex_part)
74        .map_err(|e| AcdpError::InvalidLogProof(format!("hash '{prefixed}': {e}")))?;
75    bytes.try_into().map_err(|_| {
76        AcdpError::InvalidLogProof(format!("hash '{prefixed}' does not encode 32 bytes"))
77    })
78}
79
80/// Encode a raw 32-byte digest in the repository-wide wire form
81/// `"sha256:" + lowercase_hex(...)` (RFC-ACDP-0012 §2).
82pub fn encode_sha256_hex(digest: &[u8; 32]) -> String {
83    format!("sha256:{}", hex::encode(digest))
84}
85
86/// Split a `log_id` into `(registry_did, instance)` per RFC-ACDP-0012
87/// §6: `"<registry_did>/log/<instance>"`, where `<registry_did>` is a
88/// `did:web` DID and `<instance>` matches `[a-z0-9-]{1,32}`.
89pub fn parse_log_id(log_id: &str) -> Result<(&str, &str), AcdpError> {
90    let (did, instance) = log_id.split_once("/log/").ok_or_else(|| {
91        AcdpError::InvalidLogProof(format!(
92            "log_id '{log_id}' is not '<registry_did>/log/<instance>' (RFC-ACDP-0012 §6)"
93        ))
94    })?;
95    let msi = did.strip_prefix("did:web:").ok_or_else(|| {
96        AcdpError::InvalidLogProof(format!(
97            "log_id '{log_id}' registry DID must be did:web (RFC-ACDP-0012 §6)"
98        ))
99    })?;
100    if msi.is_empty()
101        || !msi
102            .bytes()
103            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'%' | b':' | b'-'))
104    {
105        return Err(AcdpError::InvalidLogProof(format!(
106            "log_id '{log_id}' has a malformed did:web method-specific identifier"
107        )));
108    }
109    if instance.is_empty()
110        || instance.len() > 32
111        || !instance
112            .bytes()
113            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
114    {
115        return Err(AcdpError::InvalidLogProof(format!(
116            "log_id '{log_id}' instance component must match [a-z0-9-]{{1,32}} (RFC-ACDP-0012 §6)"
117        )));
118    }
119    Ok((did, instance))
120}
121
122// ── Leaf ─────────────────────────────────────────────────────────────────────
123
124/// One transparency-log leaf: the closed object binding one accepted
125/// publish event (RFC-ACDP-0012 §4).
126///
127/// CLOSED schema (`acdp-log-leaf.schema.json`,
128/// `additionalProperties: false`): the leaf bytes are hashed into the
129/// tree, so an unknown member would change the leaf hash; extensions
130/// require a `leaf_version` bump. Every field other than `receipt_hash`
131/// deliberately duplicates receipt fields so a verifier holding the
132/// body and its verified receipt can reconstruct the leaf byte-for-byte
133/// with no additional trust in the registry (§9.1 step 1).
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct LogLeaf {
137    /// MUST be exactly [`LOG_LEAF_VERSION`] (`"acdp-log-leaf/1"`).
138    pub leaf_version: String,
139    /// The registry-assigned context identifier (RFC-ACDP-0001 §5.5).
140    /// Exactly one leaf per `ctx_id` — bodies are immutable, so a
141    /// publish event happens once.
142    pub ctx_id: CtxId,
143    /// The registry-derived lineage identifier (RFC-ACDP-0001 §5.6).
144    pub lineage_id: LineageId,
145    /// The registry-assigned origin authority (bare DNS hostname). MUST
146    /// equal the `ctx_id` authority and the log's registry DID
147    /// authority.
148    pub origin_registry: String,
149    /// The registry-assigned creation timestamp — byte-identical to
150    /// `body.created_at` and `registry_receipt.created_at`; canonical
151    /// millisecond-precision RFC 3339 UTC, always serialized with
152    /// exactly three fractional digits.
153    #[serde(with = "ms_rfc3339")]
154    pub created_at: DateTime<Utc>,
155    /// The body's `content_hash` as verified at publish time.
156    pub content_hash: ContentHash,
157    /// The RFC-ACDP-0010 §6 fingerprint of the producer key resolved at
158    /// publish time. MUST equal `registry_receipt.key_fingerprint`.
159    pub key_fingerprint: String,
160    /// The RFC-ACDP-0010 §2 receipt hash of this context's registry
161    /// receipt (over the receipt preimage, i.e. minus `signature`) —
162    /// binding the signed receipt statement while remaining stable
163    /// across the sanctioned post-compromise re-mint (RFC-ACDP-0012 §4).
164    pub receipt_hash: String,
165}
166
167impl LogLeaf {
168    /// Parse a leaf from wire JSON, enforcing the closed schema, the
169    /// exact `leaf_version`, and the canonical millisecond byte form of
170    /// the RAW `created_at` (checked before parsing normalization).
171    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
172        let leaf = Self::deserialize(value)
173            .map_err(|e| AcdpError::InvalidLogProof(format!("log leaf does not parse: {e}")))?;
174        if leaf.leaf_version != LOG_LEAF_VERSION {
175            return Err(AcdpError::InvalidLogProof(format!(
176                "log leaf leaf_version '{}' ≠ '{LOG_LEAF_VERSION}' (RFC-ACDP-0012 §4)",
177                leaf.leaf_version
178            )));
179        }
180        let raw = value
181            .get("created_at")
182            .and_then(|v| v.as_str())
183            .ok_or_else(|| {
184                AcdpError::InvalidLogProof("log leaf created_at missing or not a string".into())
185            })?;
186        if !is_canonical_ms_utc(raw) {
187            return Err(AcdpError::InvalidLogProof(format!(
188                "log leaf created_at '{raw}' is not canonical millisecond-precision RFC 3339 \
189                 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0012 §4)"
190            )));
191        }
192        Ok(leaf)
193    }
194
195    /// The **leaf encoding** — the exact bytes that are hashed: the JCS
196    /// canonicalization (RFC 8785) of the leaf object, UTF-8. There is
197    /// no exclusion set: every member is part of the leaf bytes
198    /// (RFC-ACDP-0012 §4).
199    pub fn canonical_bytes(&self) -> Result<Vec<u8>, AcdpError> {
200        try_canonicalize_value(&serde_json::to_value(self)?)
201    }
202
203    /// `SHA-256(0x00 ‖ JCS(leaf))` — the §5.1 leaf hash, raw digest.
204    pub fn leaf_hash(&self) -> Result<[u8; 32], AcdpError> {
205        Ok(acdp_crypto::merkle::leaf_hash(&self.canonical_bytes()?))
206    }
207
208    /// The §5.1 leaf hash in wire form (`"sha256:<hex>"`).
209    pub fn leaf_hash_hex(&self) -> Result<String, AcdpError> {
210        Ok(encode_sha256_hex(&self.leaf_hash()?))
211    }
212}
213
214// ── Checkpoint (signed tree head) ────────────────────────────────────────────
215
216/// A registry-signed **checkpoint** (signed tree head): the registry's
217/// commitment that, as of `timestamp`, the log `log_id` has `tree_size`
218/// leaves and Merkle root `root_hash` (RFC-ACDP-0012 §6).
219///
220/// CLOSED schema (`acdp-log-checkpoint.schema.json`,
221/// `additionalProperties: false`): every field is signed, so an unknown
222/// member would change the preimage. The signing construction is
223/// RFC-ACDP-0010 §5 **verbatim** — same receipt signing key, same
224/// `"remove the signature member"` preimage rule, same ASCII
225/// `"sha256:<hex>"` signing input — with `checkpoint_version` as the
226/// in-preimage domain separator.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct LogCheckpoint {
230    /// MUST be exactly [`LOG_CHECKPOINT_VERSION`] (`"acdp-log/1"`).
231    pub checkpoint_version: String,
232    /// The log instantiation identifier
233    /// `"<registry_did>/log/<instance>"` (§6). A new instance component
234    /// is an explicit, detectable history reset (§7.4).
235    pub log_id: String,
236    /// The number of leaves this checkpoint commits to (0 is a valid,
237    /// empty log).
238    pub tree_size: u64,
239    /// `"sha256:" + lowercase_hex(MTH(D[tree_size]))` per §5.2.
240    pub root_hash: String,
241    /// Registry-clock time at which this checkpoint was evaluated and
242    /// signed; canonical millisecond-precision RFC 3339 UTC. Registry-
243    /// asserted (§13) — consumers bound it with [`Self::check_timestamp_skew`]
244    /// and their own freshness policy (§7.2).
245    #[serde(with = "ms_rfc3339")]
246    pub timestamp: DateTime<Utc>,
247    /// The registry's signature over the checkpoint hash (§6 — the
248    /// RFC-ACDP-0010 §5 construction verbatim). `signature.key_id` MUST
249    /// be a DID URL under the `registry_did` embedded in `log_id`.
250    pub signature: Signature,
251}
252
253impl LogCheckpoint {
254    /// §9.3 step 1 — schema-closed parse: exact `checkpoint_version`,
255    /// well-formed `log_id` and `root_hash`, canonical millisecond byte
256    /// form of the RAW `timestamp` (checked before parsing
257    /// normalization), and `signature.key_id` under the `log_id`'s
258    /// registry DID.
259    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
260        let checkpoint = Self::deserialize(value).map_err(|e| {
261            AcdpError::InvalidLogProof(format!("log_checkpoint does not parse: {e}"))
262        })?;
263        if checkpoint.checkpoint_version != LOG_CHECKPOINT_VERSION {
264            return Err(AcdpError::InvalidLogProof(format!(
265                "log_checkpoint checkpoint_version '{}' ≠ '{LOG_CHECKPOINT_VERSION}' \
266                 (RFC-ACDP-0012 §9.3 step 1)",
267                checkpoint.checkpoint_version
268            )));
269        }
270        parse_log_id(&checkpoint.log_id)?;
271        decode_sha256_hex(&checkpoint.root_hash)?;
272        let raw = value
273            .get("timestamp")
274            .and_then(|v| v.as_str())
275            .ok_or_else(|| {
276                AcdpError::InvalidLogProof(
277                    "log_checkpoint timestamp missing or not a string".into(),
278                )
279            })?;
280        if !is_canonical_ms_utc(raw) {
281            return Err(AcdpError::InvalidLogProof(format!(
282                "log_checkpoint timestamp '{raw}' is not canonical millisecond-precision \
283                 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0012 §9.3 step 4)"
284            )));
285        }
286        // signature.key_id MUST be a DID URL under the registry DID
287        // embedded in log_id (§6).
288        checkpoint.registry_did()?;
289        Ok(checkpoint)
290    }
291
292    /// The registry DID embedded in `log_id`, after checking that
293    /// `signature.key_id` is a DID URL under it (RFC-ACDP-0012 §6).
294    pub fn registry_did(&self) -> Result<&str, AcdpError> {
295        let (did, _instance) = parse_log_id(&self.log_id)?;
296        match self.signature.key_id.split_once('#') {
297            Some((key_did, frag)) if key_did == did && !frag.is_empty() => Ok(did),
298            _ => Err(AcdpError::InvalidLogProof(format!(
299                "log_checkpoint signature.key_id '{}' is not a DID URL under the log_id's \
300                 registry DID '{did}' (RFC-ACDP-0012 §6)",
301                self.signature.key_id
302            ))),
303        }
304    }
305
306    /// Compute the §6 checkpoint hash from the RAW wire JSON of a
307    /// checkpoint (the value minus `signature`, JCS-canonicalized as
308    /// received, SHA-256'd). Verifiers MUST hash the checkpoint exactly
309    /// as received — the same raw-JSON rule as
310    /// [`crate::receipt::RegistryReceipt::preimage_hash_of_value`].
311    pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
312        preimage_hash_of_object_with(value, "log_checkpoint", AcdpError::InvalidLogProof)
313    }
314
315    /// Compute the checkpoint hash from the struct. Used at MINT time
316    /// (the struct's serializer emits the canonical three-digit-
317    /// millisecond `timestamp`); verifiers should prefer
318    /// [`Self::preimage_hash_of_value`] over the raw wire JSON.
319    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
320        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
321    }
322
323    /// The raw 32-byte digest `root_hash` encodes.
324    pub fn root_hash_bytes(&self) -> Result<[u8; 32], AcdpError> {
325        decode_sha256_hex(&self.root_hash)
326    }
327
328    /// §9.3 step 2 — verify the checkpoint signature against a known
329    /// registry public key (pure — no DID resolution; the `client`
330    /// feature's `verify_log_checkpoint_value` resolves the registry
331    /// DID and calls this).
332    pub fn verify_signature_with_key(
333        &self,
334        registry_pub_ed25519: Option<&[u8; 32]>,
335        registry_pub_p256_sec1: Option<&[u8]>,
336    ) -> Result<(), AcdpError> {
337        let hash = self.preimage_hash()?;
338        self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
339    }
340
341    /// Like [`Self::verify_signature_with_key`] but over an
342    /// already-computed checkpoint hash — pair with
343    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
344    pub fn verify_signature_against_hash(
345        &self,
346        hash: &ContentHash,
347        registry_pub_ed25519: Option<&[u8; 32]>,
348        registry_pub_p256_sec1: Option<&[u8]>,
349    ) -> Result<(), AcdpError> {
350        verify_signature_over_hash_with(
351            &self.signature,
352            hash,
353            registry_pub_ed25519,
354            registry_pub_p256_sec1,
355            "log_checkpoint",
356            AcdpError::InvalidLogProof,
357        )
358    }
359
360    /// §9.3 step 3 — registry binding (pure): the `registry_did` prefix
361    /// of `log_id` MUST be `did:web:<authority>` where `<authority>` is
362    /// the authority the checkpoint was fetched from AND equal
363    /// `capabilities.registry_did`; the DID portion of
364    /// `signature.key_id` MUST equal that DID (already enforced by
365    /// [`Self::registry_did`]).
366    pub fn cross_check_registry_binding(
367        &self,
368        serving_authority: &str,
369        capabilities_registry_did: &str,
370    ) -> Result<(), AcdpError> {
371        let did = self.registry_did()?;
372        let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
373        if did != expected_did {
374            return Err(AcdpError::InvalidLogProof(format!(
375                "log_checkpoint log_id registry DID '{did}' ≠ serving authority's DID \
376                 '{expected_did}' (RFC-ACDP-0012 §9.3 step 3)"
377            )));
378        }
379        if did != capabilities_registry_did {
380            return Err(AcdpError::InvalidLogProof(format!(
381                "log_checkpoint log_id registry DID '{did}' ≠ capabilities.registry_did \
382                 '{capabilities_registry_did}' (RFC-ACDP-0012 §9.3 step 3)"
383            )));
384        }
385        Ok(())
386    }
387
388    /// §9.3 step 4 — form: `timestamp` millisecond-truncated and not in
389    /// the future beyond `max_clock_skew` (RECOMMENDED 120 s, the
390    /// RFC-ACDP-0011 §7 step 6 allowance). Staleness (an old but honest
391    /// `timestamp`) is consumer freshness policy (§7.2), evaluated
392    /// separately via [`Self::age_at`].
393    pub fn check_timestamp_skew(
394        &self,
395        now: DateTime<Utc>,
396        max_clock_skew: chrono::Duration,
397    ) -> Result<(), AcdpError> {
398        if self.timestamp.timestamp_subsec_nanos() % 1_000_000 != 0 {
399            return Err(AcdpError::InvalidLogProof(
400                "log_checkpoint timestamp is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
401            ));
402        }
403        if self.timestamp > now + max_clock_skew {
404            return Err(AcdpError::InvalidLogProof(format!(
405                "log_checkpoint timestamp '{}' is in the future beyond the {}s clock-skew \
406                 allowance (consumer clock '{}') (RFC-ACDP-0012 §9.3 step 4)",
407                self.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
408                max_clock_skew.num_seconds(),
409                now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
410            )));
411        }
412        Ok(())
413    }
414
415    /// The checkpoint's age at `now` — the input to the consumer's §7.2
416    /// freshness policy (RECOMMENDED maximum: 300 seconds).
417    pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
418        now - self.timestamp
419    }
420}
421
422// ── Inclusion proof ──────────────────────────────────────────────────────────
423
424/// An inclusion proof that one leaf is committed by a checkpointed
425/// transparency-log tree (RFC-ACDP-0012 §8.2, §9.1): the RFC 6962
426/// §2.1.1 audit path for `leaf_index` at `tree_size`, together with the
427/// signed checkpoint it verifies against.
428///
429/// Returned by `GET /log/proof` (inclusion mode) and OPTIONALLY carried
430/// as the top-level `log_inclusion` member of the full-retrieval
431/// envelope (§10 — a **sibling** of `registry_receipt`, never a member
432/// of it). CLOSED schema: a proof is arithmetic evidence with no
433/// extension surface.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(deny_unknown_fields)]
436pub struct LogInclusion {
437    /// The log instantiation this proof belongs to. MUST equal
438    /// `log_checkpoint.log_id` (§9.1 step 4).
439    pub log_id: String,
440    /// The 0-based append-order position of the leaf. MUST be
441    /// `< tree_size`.
442    pub leaf_index: u64,
443    /// The tree size the proof is computed against. MUST equal
444    /// `log_checkpoint.tree_size` (§9.1 step 4).
445    pub tree_size: u64,
446    /// The RFC 6962 §2.1.1 audit path `PATH(leaf_index, D[tree_size])`,
447    /// ordered from the lowest tree level upward, each element
448    /// `"sha256:<hex>"`. May be empty (`tree_size` 1).
449    pub inclusion_path: Vec<String>,
450    /// The signed checkpoint this proof verifies against (§9.3).
451    pub log_checkpoint: LogCheckpoint,
452    /// OPTIONAL convenience echo of the leaf, present on inclusion-mode
453    /// `GET /log/proof` responses only when the requester is authorized
454    /// to retrieve the context (§8.2), and absent from the
455    /// retrieval-envelope `log_inclusion` member (§10). Verifiers MUST
456    /// NOT trust it — the leaf is independently reconstructed from
457    /// verified body and receipt material (§9.1 step 1).
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub leaf: Option<LogLeaf>,
460}
461
462impl LogInclusion {
463    /// Parse an inclusion proof from the JSON value carried in
464    /// [`crate::body::FullContext::log_inclusion`] (or a
465    /// `GET /log/proof` inclusion-mode response), enforcing the closed
466    /// schema, the embedded checkpoint's §9.3 step 1 form, and
467    /// `tree_size ≥ 1`.
468    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
469        let inclusion = Self::deserialize(value).map_err(|e| {
470            AcdpError::InvalidLogProof(format!("log_inclusion does not parse: {e}"))
471        })?;
472        if inclusion.tree_size < 1 {
473            return Err(AcdpError::InvalidLogProof(
474                "log_inclusion tree_size must be >= 1 (an empty tree has no members)".into(),
475            ));
476        }
477        // The embedded checkpoint's schema-closed invariants (§9.3
478        // step 1) — reparse from the raw member so the RAW timestamp
479        // byte form is checked too.
480        if let Some(cp) = value.get("log_checkpoint") {
481            LogCheckpoint::from_value(cp)?;
482        }
483        if let Some(leaf) = value.get("leaf") {
484            LogLeaf::from_value(leaf)?;
485        }
486        Ok(inclusion)
487    }
488
489    /// §9.1 step 4 — binding checks: `tree_size` equals
490    /// `log_checkpoint.tree_size`, `log_id` equals
491    /// `log_checkpoint.log_id`, and `leaf_index < tree_size`.
492    pub fn cross_check_binding(&self) -> Result<(), AcdpError> {
493        if self.tree_size != self.log_checkpoint.tree_size {
494            return Err(AcdpError::InvalidLogProof(format!(
495                "log_inclusion tree_size {} ≠ log_checkpoint.tree_size {} \
496                 (RFC-ACDP-0012 §9.1 step 4)",
497                self.tree_size, self.log_checkpoint.tree_size
498            )));
499        }
500        if self.log_id != self.log_checkpoint.log_id {
501            return Err(AcdpError::InvalidLogProof(format!(
502                "log_inclusion log_id '{}' ≠ log_checkpoint.log_id '{}' \
503                 (RFC-ACDP-0012 §9.1 step 4)",
504                self.log_id, self.log_checkpoint.log_id
505            )));
506        }
507        if self.leaf_index >= self.tree_size {
508            return Err(AcdpError::InvalidLogProof(format!(
509                "log_inclusion leaf_index {} is not < tree_size {} (RFC-ACDP-0012 §9.1 step 4)",
510                self.leaf_index, self.tree_size
511            )));
512        }
513        Ok(())
514    }
515
516    /// §9.1 steps 4–6 — the pure arithmetic half of inclusion
517    /// verification: binding checks, then fold the audit path over
518    /// `leaf_hash` (the §5.1 hash of the *independently reconstructed*
519    /// leaf — never an echoed one) and compare against the checkpoint's
520    /// `root_hash`. Checkpoint signature verification (§9.3 step 2) is
521    /// separate — see [`LogCheckpoint::verify_signature_with_key`] and
522    /// the `client` feature's `verify_log_inclusion_value`.
523    pub fn verify_leaf_hash(&self, leaf_hash: &[u8; 32]) -> Result<(), AcdpError> {
524        self.cross_check_binding()?;
525        let path = self
526            .inclusion_path
527            .iter()
528            .map(|p| decode_sha256_hex(p))
529            .collect::<Result<Vec<_>, _>>()?;
530        let root = self.log_checkpoint.root_hash_bytes()?;
531        if !acdp_crypto::merkle::verify_inclusion(
532            leaf_hash,
533            self.leaf_index,
534            self.tree_size,
535            &path,
536            &root,
537        ) {
538            return Err(AcdpError::InvalidLogProof(format!(
539                "inclusion_path for leaf_index {} does not fold to the checkpoint root_hash \
540                 '{}' at tree_size {} (RFC-ACDP-0012 §9.1 step 6)",
541                self.leaf_index, self.log_checkpoint.root_hash, self.tree_size
542            )));
543        }
544        Ok(())
545    }
546
547    /// Convenience: [`Self::verify_leaf_hash`] over a reconstructed
548    /// [`LogLeaf`] (§9.1 steps 1–2 + 4–6). Pass the leaf built from
549    /// *verified* material — never the echoed `leaf` member.
550    pub fn verify_reconstructed_leaf(&self, leaf: &LogLeaf) -> Result<(), AcdpError> {
551        self.verify_leaf_hash(&leaf.leaf_hash()?)
552    }
553}
554
555// ── Consistency proof ────────────────────────────────────────────────────────
556
557/// A consistency-mode `GET /log/proof` response (RFC-ACDP-0012 §8.2):
558/// the RFC 6962 §2.1.2 proof `PROOF(first, D[second])` that the tree at
559/// `first_tree_size` is a prefix of the tree at `second_tree_size` —
560/// the history-rewrite detector. The verifier supplies its own
561/// *retained* root for `first_tree_size` (that retained root is the
562/// whole point, §9.2).
563///
564/// Distinct shape from [`LogInclusion`] (the schema `$comment` is
565/// explicit); closed for the same reason — arithmetic evidence has no
566/// extension surface.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub struct LogConsistencyProof {
570    /// The log instantiation. Consistency proofs exist only within one
571    /// `log_id` (§7.4).
572    pub log_id: String,
573    /// The earlier (retained) tree size.
574    pub first_tree_size: u64,
575    /// The later tree size. MUST equal `log_checkpoint.tree_size`.
576    pub second_tree_size: u64,
577    /// `PROOF(first, D[second])`, each element `"sha256:<hex>"`; empty
578    /// when `first == second`.
579    pub consistency_path: Vec<String>,
580    /// A checkpoint at `second_tree_size` (§8.2), verified per §9.3.
581    pub log_checkpoint: LogCheckpoint,
582}
583
584impl LogConsistencyProof {
585    /// Parse a consistency proof from a `GET /log/proof`
586    /// consistency-mode response, enforcing the closed schema and the
587    /// embedded checkpoint's §9.3 step 1 form.
588    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
589        let proof = Self::deserialize(value).map_err(|e| {
590            AcdpError::InvalidLogProof(format!("consistency proof does not parse: {e}"))
591        })?;
592        if let Some(cp) = value.get("log_checkpoint") {
593            LogCheckpoint::from_value(cp)?;
594        }
595        Ok(proof)
596    }
597
598    /// §9.2 — verify the proof between the verifier's **retained**
599    /// earlier root (`first_root_hash`, wire form) and the embedded
600    /// later checkpoint's root, plus the shape bindings (`log_id`s
601    /// equal, `second_tree_size == log_checkpoint.tree_size`).
602    /// Checkpoint signature verification (§9.3) is separate.
603    ///
604    /// A failure here between two *signature-valid* checkpoints of one
605    /// `log_id` is not a soft error: it is cryptographic evidence that
606    /// the registry rewrote logged history, and consumers SHOULD retain
607    /// both checkpoints and the failing path as evidence (§9.2, §15).
608    pub fn verify_against_first_root(&self, first_root_hash: &str) -> Result<(), AcdpError> {
609        if self.log_id != self.log_checkpoint.log_id {
610            return Err(AcdpError::InvalidLogProof(format!(
611                "consistency proof log_id '{}' ≠ log_checkpoint.log_id '{}' \
612                 (consistency proofs exist only within one log_id, RFC-ACDP-0012 §7.4)",
613                self.log_id, self.log_checkpoint.log_id
614            )));
615        }
616        if self.second_tree_size != self.log_checkpoint.tree_size {
617            return Err(AcdpError::InvalidLogProof(format!(
618                "consistency proof second_tree_size {} ≠ log_checkpoint.tree_size {} \
619                 (RFC-ACDP-0012 §8.2)",
620                self.second_tree_size, self.log_checkpoint.tree_size
621            )));
622        }
623        let first_root = decode_sha256_hex(first_root_hash)?;
624        let second_root = self.log_checkpoint.root_hash_bytes()?;
625        let path = self
626            .consistency_path
627            .iter()
628            .map(|p| decode_sha256_hex(p))
629            .collect::<Result<Vec<_>, _>>()?;
630        if !acdp_crypto::merkle::verify_consistency(
631            self.first_tree_size,
632            self.second_tree_size,
633            &path,
634            &first_root,
635            &second_root,
636        ) {
637            return Err(AcdpError::InvalidLogProof(format!(
638                "consistency_path does not prove the tree at size {} is a prefix of the tree \
639                 at size {} — evidence of a logged-history rewrite; retain both checkpoints \
640                 and this path (RFC-ACDP-0012 §9.2)",
641                self.first_tree_size, self.second_tree_size
642            )));
643        }
644        Ok(())
645    }
646}
647
648// ── Checkpoint minting (registry side) ───────────────────────────────────────
649
650impl ReceiptSigner {
651    /// Mint a signed **log checkpoint** (RFC-ACDP-0012 §6): the
652    /// registry's commitment that, as of `timestamp` (truncated here to
653    /// milliseconds), the log `log_id` has `tree_size` leaves and
654    /// Merkle root `root_hash`.
655    ///
656    /// Signs with the same receipt signing key as
657    /// [`ReceiptSigner::mint`] — the log introduces **no new key role**
658    /// (§2, §6); `checkpoint_version` inside the preimage is what
659    /// domain-separates checkpoints from both receipt kinds.
660    ///
661    /// Refuses an internally inconsistent checkpoint: a `log_id` whose
662    /// registry DID is not this signer's, a malformed `log_id`
663    /// instance, or a malformed `root_hash`.
664    pub fn mint_log_checkpoint(
665        &self,
666        log_id: &str,
667        tree_size: u64,
668        root_hash: &str,
669        timestamp: DateTime<Utc>,
670    ) -> Result<LogCheckpoint, AcdpError> {
671        let (did, _instance) = parse_log_id(log_id)?;
672        if did != self.registry_did() {
673            return Err(AcdpError::SchemaViolation(format!(
674                "cannot mint a checkpoint for log_id '{log_id}' under registry_did '{}' \
675                 (RFC-ACDP-0012 §6: the log_id embeds the registry's own DID)",
676                self.registry_did()
677            )));
678        }
679        decode_sha256_hex(root_hash)?;
680        let mut checkpoint = LogCheckpoint {
681            checkpoint_version: LOG_CHECKPOINT_VERSION.to_string(),
682            log_id: log_id.to_string(),
683            tree_size,
684            root_hash: root_hash.to_string(),
685            timestamp: acdp_primitives::time::trunc_ms(timestamp),
686            signature: Signature {
687                algorithm: self.signing_key().algorithm().into(),
688                key_id: self.key_id().to_string(),
689                value: String::new(), // filled below
690            },
691        };
692        let hash = checkpoint.preimage_hash()?;
693        let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
694        checkpoint.signature.algorithm = algorithm.into();
695        checkpoint.signature.value = value;
696        Ok(checkpoint)
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use acdp_crypto::SigningKey;
704
705    const REGISTRY_DID: &str = "did:web:registry.example.com";
706    const LOG_ID: &str = "did:web:registry.example.com/log/1";
707
708    fn signer() -> ReceiptSigner {
709        ReceiptSigner::new(
710            SigningKey::from_bytes(&[0x11u8; 32]),
711            REGISTRY_DID,
712            format!("{REGISTRY_DID}#receipt-key-1"),
713        )
714        .unwrap()
715    }
716
717    fn registry_pub() -> [u8; 32] {
718        SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
719    }
720
721    fn leaf(i: u8) -> LogLeaf {
722        let ctx_id =
723            format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
724        LogLeaf {
725            leaf_version: LOG_LEAF_VERSION.into(),
726            lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
727            ctx_id: CtxId(ctx_id),
728            origin_registry: "registry.example.com".into(),
729            created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
730                .unwrap()
731                .with_timezone(&Utc),
732            content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
733            key_fingerprint: format!("sha256:{}", "c".repeat(64)),
734            receipt_hash: format!("sha256:{}", "d".repeat(64)),
735        }
736    }
737
738    #[test]
739    fn leaf_closed_schema_and_version_enforced() {
740        let l = leaf(1);
741        let wire = serde_json::to_value(&l).unwrap();
742        assert_eq!(LogLeaf::from_value(&wire).unwrap(), l);
743
744        let mut extra = wire.clone();
745        extra
746            .as_object_mut()
747            .unwrap()
748            .insert("note".into(), serde_json::json!("x"));
749        assert!(matches!(
750            LogLeaf::from_value(&extra).unwrap_err(),
751            AcdpError::InvalidLogProof(_)
752        ));
753
754        let mut wrong = wire.clone();
755        wrong["leaf_version"] = serde_json::json!("acdp-log-leaf/2");
756        assert!(LogLeaf::from_value(&wrong).is_err());
757
758        let mut bad_ts = wire.clone();
759        bad_ts["created_at"] = serde_json::json!("2026-07-01T01:00:00Z");
760        assert!(LogLeaf::from_value(&bad_ts).is_err());
761    }
762
763    #[test]
764    fn checkpoint_mint_verify_round_trip() {
765        let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
766        let cp = signer()
767            .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
768            .unwrap();
769        cp.verify_signature_with_key(Some(&registry_pub()), None)
770            .expect("freshly minted checkpoint must verify");
771        let wire = serde_json::to_value(&cp).unwrap();
772        let parsed = LogCheckpoint::from_value(&wire).unwrap();
773        let raw_hash = LogCheckpoint::preimage_hash_of_value(&wire).unwrap();
774        assert_eq!(raw_hash, cp.preimage_hash().unwrap());
775        parsed
776            .verify_signature_against_hash(&raw_hash, Some(&registry_pub()), None)
777            .unwrap();
778        parsed
779            .cross_check_registry_binding("registry.example.com", REGISTRY_DID)
780            .unwrap();
781        assert!(parsed
782            .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
783            .is_err());
784        parsed
785            .check_timestamp_skew(Utc::now(), chrono::Duration::seconds(120))
786            .unwrap();
787
788        // log-004-style: tampering root_hash after signing fails.
789        let mut tampered = wire.clone();
790        tampered["root_hash"] = serde_json::json!(format!("sha256:{}", "f".repeat(64)));
791        let t = LogCheckpoint::from_value(&tampered).unwrap();
792        let t_hash = LogCheckpoint::preimage_hash_of_value(&tampered).unwrap();
793        let err = t
794            .verify_signature_against_hash(&t_hash, Some(&registry_pub()), None)
795            .unwrap_err();
796        assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
797    }
798
799    #[test]
800    fn checkpoint_closed_schema_and_domain_separation() {
801        let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
802        let cp = signer()
803            .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
804            .unwrap();
805        let mut wire = serde_json::to_value(&cp).unwrap();
806        wire.as_object_mut()
807            .unwrap()
808            .insert("witnesses".into(), serde_json::json!([]));
809        assert!(matches!(
810            LogCheckpoint::from_value(&wire).unwrap_err(),
811            AcdpError::InvalidLogProof(_)
812        ));
813
814        let mut wire = serde_json::to_value(&cp).unwrap();
815        wire["checkpoint_version"] = serde_json::json!("acdp-lhr/1");
816        assert!(LogCheckpoint::from_value(&wire).is_err());
817
818        // A checkpoint never parses as a receipt and vice versa.
819        let wire = serde_json::to_value(&cp).unwrap();
820        assert!(crate::receipt::LineageHeadReceipt::from_value(&wire).is_err());
821
822        // Foreign log_id refused at mint; malformed instance refused.
823        assert!(signer()
824            .mint_log_checkpoint("did:web:evil.example.com/log/1", 0, &root, Utc::now())
825            .is_err());
826        assert!(signer()
827            .mint_log_checkpoint(
828                "did:web:registry.example.com/log/UPPER",
829                0,
830                &root,
831                Utc::now()
832            )
833            .is_err());
834        assert!(signer()
835            .mint_log_checkpoint(LOG_ID, 0, "sha256:short", Utc::now())
836            .is_err());
837    }
838
839    /// Inclusion + consistency round trip over minted leaves, plus the
840    /// log-002-style tampered-path rejection.
841    #[test]
842    fn inclusion_and_consistency_round_trip() {
843        let leaves: Vec<LogLeaf> = (0..5).map(leaf).collect();
844        let hashes: Vec<[u8; 32]> = leaves.iter().map(|l| l.leaf_hash().unwrap()).collect();
845        let root = acdp_crypto::merkle_tree_hash(&hashes);
846        let cp = signer()
847            .mint_log_checkpoint(LOG_ID, 5, &encode_sha256_hex(&root), Utc::now())
848            .unwrap();
849
850        for (i, l) in leaves.iter().enumerate() {
851            let path = acdp_crypto::inclusion_path(i, &hashes).unwrap();
852            let inclusion = LogInclusion {
853                log_id: LOG_ID.into(),
854                leaf_index: i as u64,
855                tree_size: 5,
856                inclusion_path: path.iter().map(encode_sha256_hex).collect(),
857                log_checkpoint: cp.clone(),
858                leaf: None,
859            };
860            inclusion.verify_reconstructed_leaf(l).unwrap();
861            // Wire round trip through the closed parse.
862            let wire = serde_json::to_value(&inclusion).unwrap();
863            assert!(wire.get("leaf").is_none(), "absent, never null");
864            LogInclusion::from_value(&wire)
865                .unwrap()
866                .verify_leaf_hash(&hashes[i])
867                .unwrap();
868
869            // Tampered path element → §9.1 step 6 failure.
870            if !inclusion.inclusion_path.is_empty() {
871                let mut bad = inclusion.clone();
872                let mut raw = decode_sha256_hex(&bad.inclusion_path[0]).unwrap();
873                raw[0] ^= 1;
874                bad.inclusion_path[0] = encode_sha256_hex(&raw);
875                let err = bad.verify_reconstructed_leaf(l).unwrap_err();
876                assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
877            }
878        }
879
880        // Binding failures (§9.1 step 4).
881        let path = acdp_crypto::inclusion_path(0, &hashes).unwrap();
882        let mk = |leaf_index, tree_size, log_id: &str| LogInclusion {
883            log_id: log_id.into(),
884            leaf_index,
885            tree_size,
886            inclusion_path: path.iter().map(encode_sha256_hex).collect(),
887            log_checkpoint: cp.clone(),
888            leaf: None,
889        };
890        assert!(mk(0, 4, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); // ≠ checkpoint size
891        assert!(mk(5, 5, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); // index ≥ size
892        assert!(mk(0, 5, "did:web:registry.example.com/log/2")
893            .verify_leaf_hash(&hashes[0])
894            .is_err()); // log_id mismatch
895
896        // Consistency 3 → 5.
897        let first_root = acdp_crypto::merkle_tree_hash(&hashes[..3]);
898        let proof = LogConsistencyProof {
899            log_id: LOG_ID.into(),
900            first_tree_size: 3,
901            second_tree_size: 5,
902            consistency_path: acdp_crypto::consistency_proof(3, &hashes)
903                .unwrap()
904                .iter()
905                .map(encode_sha256_hex)
906                .collect(),
907            log_checkpoint: cp.clone(),
908        };
909        proof
910            .verify_against_first_root(&encode_sha256_hex(&first_root))
911            .unwrap();
912        // A different retained root → history-rewrite evidence.
913        let err = proof
914            .verify_against_first_root(&format!("sha256:{}", "e".repeat(64)))
915            .unwrap_err();
916        assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
917    }
918
919    #[test]
920    fn log_id_parsing() {
921        assert_eq!(
922            parse_log_id(LOG_ID).unwrap(),
923            ("did:web:registry.example.com", "1")
924        );
925        for bad in [
926            "did:web:registry.example.com",                       // no /log/
927            "did:key:z6Mk/log/1",                                 // not did:web
928            "did:web:registry.example.com/log/",                  // empty instance
929            "did:web:registry.example.com/log/UP",                // uppercase instance
930            &format!("did:web:r.example/log/{}", "a".repeat(33)), // too long
931        ] {
932            assert!(parse_log_id(bad).is_err(), "{bad} must be rejected");
933        }
934    }
935}