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    /// Build the §4 leaf for one receipted publish event. A verifier
214    /// uses it for §9.1 step 1, and a registry uses it at §7.1 append
215    /// time, so both sides hash the same bytes.
216    ///
217    /// Pass only *verified* material:
218    /// - `body` is the accepted body. Its `ctx_id`, `lineage_id`,
219    ///   `origin_registry` and `created_at` are used.
220    /// - `content_hash` is recomputed from the body, never the echoed
221    ///   value.
222    /// - `key_fingerprint` is the RFC-ACDP-0010 §6 fingerprint of the
223    ///   resolved producer key.
224    /// - `receipt_value` is the raw wire receipt. `receipt_hash` is its
225    ///   §2 preimage hash (minus `signature`), recomputed here.
226    ///
227    /// # Errors
228    /// [`AcdpError::InvalidReceipt`] when `receipt_value` is not a JSON
229    /// object.
230    pub fn for_publish(
231        body: &crate::body::Body,
232        content_hash: &ContentHash,
233        key_fingerprint: &str,
234        receipt_value: &serde_json::Value,
235    ) -> Result<Self, AcdpError> {
236        let receipt_hash = crate::receipt::RegistryReceipt::preimage_hash_of_value(receipt_value)?;
237        Ok(Self {
238            leaf_version: LOG_LEAF_VERSION.to_string(),
239            ctx_id: body.ctx_id.clone(),
240            lineage_id: body.lineage_id.clone(),
241            origin_registry: body.origin_registry.clone(),
242            created_at: body.created_at,
243            content_hash: content_hash.clone(),
244            key_fingerprint: key_fingerprint.to_string(),
245            receipt_hash: receipt_hash.0,
246        })
247    }
248}
249
250// ── Checkpoint (signed tree head) ────────────────────────────────────────────
251
252/// A registry-signed **checkpoint** (signed tree head): the registry's
253/// commitment that, as of `timestamp`, the log `log_id` has `tree_size`
254/// leaves and Merkle root `root_hash` (RFC-ACDP-0012 §6).
255///
256/// CLOSED schema (`acdp-log-checkpoint.schema.json`,
257/// `additionalProperties: false`): every field is signed, so an unknown
258/// member would change the preimage. The signing construction is
259/// RFC-ACDP-0010 §5 **verbatim** — same receipt signing key, same
260/// `"remove the signature member"` preimage rule, same ASCII
261/// `"sha256:<hex>"` signing input — with `checkpoint_version` as the
262/// in-preimage domain separator.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct LogCheckpoint {
266    /// MUST be exactly [`LOG_CHECKPOINT_VERSION`] (`"acdp-log/1"`).
267    pub checkpoint_version: String,
268    /// The log instantiation identifier
269    /// `"<registry_did>/log/<instance>"` (§6). A new instance component
270    /// is an explicit, detectable history reset (§7.4).
271    pub log_id: String,
272    /// The number of leaves this checkpoint commits to (0 is a valid,
273    /// empty log).
274    pub tree_size: u64,
275    /// `"sha256:" + lowercase_hex(MTH(D[tree_size]))` per §5.2.
276    pub root_hash: String,
277    /// Registry-clock time at which this checkpoint was evaluated and
278    /// signed; canonical millisecond-precision RFC 3339 UTC. Registry-
279    /// asserted (§13) — consumers bound it with [`Self::check_timestamp_skew`]
280    /// and their own freshness policy (§7.2).
281    #[serde(with = "ms_rfc3339")]
282    pub timestamp: DateTime<Utc>,
283    /// The registry's signature over the checkpoint hash (§6 — the
284    /// RFC-ACDP-0010 §5 construction verbatim). `signature.key_id` MUST
285    /// be a DID URL under the `registry_did` embedded in `log_id`.
286    pub signature: Signature,
287}
288
289impl LogCheckpoint {
290    /// §9.3 step 1 — schema-closed parse: exact `checkpoint_version`,
291    /// well-formed `log_id` and `root_hash`, canonical millisecond byte
292    /// form of the RAW `timestamp` (checked before parsing
293    /// normalization), and `signature.key_id` under the `log_id`'s
294    /// registry DID.
295    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
296        let checkpoint = Self::deserialize(value).map_err(|e| {
297            AcdpError::InvalidLogProof(format!("log_checkpoint does not parse: {e}"))
298        })?;
299        if checkpoint.checkpoint_version != LOG_CHECKPOINT_VERSION {
300            return Err(AcdpError::InvalidLogProof(format!(
301                "log_checkpoint checkpoint_version '{}' ≠ '{LOG_CHECKPOINT_VERSION}' \
302                 (RFC-ACDP-0012 §9.3 step 1)",
303                checkpoint.checkpoint_version
304            )));
305        }
306        parse_log_id(&checkpoint.log_id)?;
307        decode_sha256_hex(&checkpoint.root_hash)?;
308        let raw = value
309            .get("timestamp")
310            .and_then(|v| v.as_str())
311            .ok_or_else(|| {
312                AcdpError::InvalidLogProof(
313                    "log_checkpoint timestamp missing or not a string".into(),
314                )
315            })?;
316        if !is_canonical_ms_utc(raw) {
317            return Err(AcdpError::InvalidLogProof(format!(
318                "log_checkpoint timestamp '{raw}' is not canonical millisecond-precision \
319                 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0012 §9.3 step 4)"
320            )));
321        }
322        // signature.key_id MUST be a DID URL under the registry DID
323        // embedded in log_id (§6).
324        checkpoint.registry_did()?;
325        Ok(checkpoint)
326    }
327
328    /// The registry DID embedded in `log_id`, after checking that
329    /// `signature.key_id` is a DID URL under it (RFC-ACDP-0012 §6).
330    pub fn registry_did(&self) -> Result<&str, AcdpError> {
331        let (did, _instance) = parse_log_id(&self.log_id)?;
332        match self.signature.key_id.split_once('#') {
333            Some((key_did, frag)) if key_did == did && !frag.is_empty() => Ok(did),
334            _ => Err(AcdpError::InvalidLogProof(format!(
335                "log_checkpoint signature.key_id '{}' is not a DID URL under the log_id's \
336                 registry DID '{did}' (RFC-ACDP-0012 §6)",
337                self.signature.key_id
338            ))),
339        }
340    }
341
342    /// Compute the §6 checkpoint hash from the RAW wire JSON of a
343    /// checkpoint (the value minus `signature`, JCS-canonicalized as
344    /// received, SHA-256'd). Verifiers MUST hash the checkpoint exactly
345    /// as received — the same raw-JSON rule as
346    /// [`crate::receipt::RegistryReceipt::preimage_hash_of_value`].
347    pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
348        preimage_hash_of_object_with(value, "log_checkpoint", AcdpError::InvalidLogProof)
349    }
350
351    /// Compute the checkpoint hash from the struct. Used at MINT time
352    /// (the struct's serializer emits the canonical three-digit-
353    /// millisecond `timestamp`); verifiers should prefer
354    /// [`Self::preimage_hash_of_value`] over the raw wire JSON.
355    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
356        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
357    }
358
359    /// The raw 32-byte digest `root_hash` encodes.
360    pub fn root_hash_bytes(&self) -> Result<[u8; 32], AcdpError> {
361        decode_sha256_hex(&self.root_hash)
362    }
363
364    /// §9.3 step 2 — verify the checkpoint signature against a known
365    /// registry public key (pure — no DID resolution; the `client`
366    /// feature's `verify_log_checkpoint_value` resolves the registry
367    /// DID and calls this).
368    pub fn verify_signature_with_key(
369        &self,
370        registry_pub_ed25519: Option<&[u8; 32]>,
371        registry_pub_p256_sec1: Option<&[u8]>,
372    ) -> Result<(), AcdpError> {
373        let hash = self.preimage_hash()?;
374        self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
375    }
376
377    /// Like [`Self::verify_signature_with_key`] but over an
378    /// already-computed checkpoint hash — pair with
379    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
380    pub fn verify_signature_against_hash(
381        &self,
382        hash: &ContentHash,
383        registry_pub_ed25519: Option<&[u8; 32]>,
384        registry_pub_p256_sec1: Option<&[u8]>,
385    ) -> Result<(), AcdpError> {
386        verify_signature_over_hash_with(
387            &self.signature,
388            hash,
389            registry_pub_ed25519,
390            registry_pub_p256_sec1,
391            "log_checkpoint",
392            AcdpError::InvalidLogProof,
393        )
394    }
395
396    /// §9.3 step 3 — registry binding (pure): the `registry_did` prefix
397    /// of `log_id` MUST be `did:web:<authority>` where `<authority>` is
398    /// the authority the checkpoint was fetched from AND equal
399    /// `capabilities.registry_did`; the DID portion of
400    /// `signature.key_id` MUST equal that DID (already enforced by
401    /// [`Self::registry_did`]).
402    pub fn cross_check_registry_binding(
403        &self,
404        serving_authority: &str,
405        capabilities_registry_did: &str,
406    ) -> Result<(), AcdpError> {
407        let did = self.registry_did()?;
408        let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
409        if did != expected_did {
410            return Err(AcdpError::InvalidLogProof(format!(
411                "log_checkpoint log_id registry DID '{did}' ≠ serving authority's DID \
412                 '{expected_did}' (RFC-ACDP-0012 §9.3 step 3)"
413            )));
414        }
415        if did != capabilities_registry_did {
416            return Err(AcdpError::InvalidLogProof(format!(
417                "log_checkpoint log_id registry DID '{did}' ≠ capabilities.registry_did \
418                 '{capabilities_registry_did}' (RFC-ACDP-0012 §9.3 step 3)"
419            )));
420        }
421        Ok(())
422    }
423
424    /// §9.3 step 4 — form: `timestamp` millisecond-truncated and not in
425    /// the future beyond `max_clock_skew` (RECOMMENDED 120 s, the
426    /// RFC-ACDP-0011 §7 step 6 allowance). Staleness (an old but honest
427    /// `timestamp`) is consumer freshness policy (§7.2), evaluated
428    /// separately via [`Self::age_at`].
429    pub fn check_timestamp_skew(
430        &self,
431        now: DateTime<Utc>,
432        max_clock_skew: chrono::Duration,
433    ) -> Result<(), AcdpError> {
434        if !self
435            .timestamp
436            .timestamp_subsec_nanos()
437            .is_multiple_of(1_000_000)
438        {
439            return Err(AcdpError::InvalidLogProof(
440                "log_checkpoint timestamp is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
441            ));
442        }
443        if self.timestamp > now + max_clock_skew {
444            return Err(AcdpError::InvalidLogProof(format!(
445                "log_checkpoint timestamp '{}' is in the future beyond the {}s clock-skew \
446                 allowance (consumer clock '{}') (RFC-ACDP-0012 §9.3 step 4)",
447                self.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
448                max_clock_skew.num_seconds(),
449                now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
450            )));
451        }
452        Ok(())
453    }
454
455    /// The checkpoint's age at `now` — the input to the consumer's §7.2
456    /// freshness policy (RECOMMENDED maximum: 300 seconds).
457    pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
458        now - self.timestamp
459    }
460}
461
462// ── Inclusion proof ──────────────────────────────────────────────────────────
463
464/// An inclusion proof that one leaf is committed by a checkpointed
465/// transparency-log tree (RFC-ACDP-0012 §8.2, §9.1): the RFC 6962
466/// §2.1.1 audit path for `leaf_index` at `tree_size`, together with the
467/// signed checkpoint it verifies against.
468///
469/// Returned by `GET /log/proof` (inclusion mode) and OPTIONALLY carried
470/// as the top-level `log_inclusion` member of the full-retrieval
471/// envelope (§10 — a **sibling** of `registry_receipt`, never a member
472/// of it). CLOSED schema: a proof is arithmetic evidence with no
473/// extension surface.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(deny_unknown_fields)]
476pub struct LogInclusion {
477    /// The log instantiation this proof belongs to. MUST equal
478    /// `log_checkpoint.log_id` (§9.1 step 4).
479    pub log_id: String,
480    /// The 0-based append-order position of the leaf. MUST be
481    /// `< tree_size`.
482    pub leaf_index: u64,
483    /// The tree size the proof is computed against. MUST equal
484    /// `log_checkpoint.tree_size` (§9.1 step 4).
485    pub tree_size: u64,
486    /// The RFC 6962 §2.1.1 audit path `PATH(leaf_index, D[tree_size])`,
487    /// ordered from the lowest tree level upward, each element
488    /// `"sha256:<hex>"`. May be empty (`tree_size` 1).
489    pub inclusion_path: Vec<String>,
490    /// The signed checkpoint this proof verifies against (§9.3).
491    pub log_checkpoint: LogCheckpoint,
492    /// OPTIONAL convenience echo of the leaf, present on inclusion-mode
493    /// `GET /log/proof` responses only when the requester is authorized
494    /// to retrieve the context (§8.2), and absent from the
495    /// retrieval-envelope `log_inclusion` member (§10). Verifiers MUST
496    /// NOT trust it — the leaf is independently reconstructed from
497    /// verified body and receipt material (§9.1 step 1).
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub leaf: Option<LogLeaf>,
500}
501
502impl LogInclusion {
503    /// Parse an inclusion proof from the JSON value carried in
504    /// [`crate::body::FullContext::log_inclusion`] (or a
505    /// `GET /log/proof` inclusion-mode response), enforcing the closed
506    /// schema, the embedded checkpoint's §9.3 step 1 form, and
507    /// `tree_size ≥ 1`.
508    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
509        let inclusion = Self::deserialize(value).map_err(|e| {
510            AcdpError::InvalidLogProof(format!("log_inclusion does not parse: {e}"))
511        })?;
512        if inclusion.tree_size < 1 {
513            return Err(AcdpError::InvalidLogProof(
514                "log_inclusion tree_size must be >= 1 (an empty tree has no members)".into(),
515            ));
516        }
517        // The embedded checkpoint's schema-closed invariants (§9.3
518        // step 1) — reparse from the raw member so the RAW timestamp
519        // byte form is checked too.
520        if let Some(cp) = value.get("log_checkpoint") {
521            LogCheckpoint::from_value(cp)?;
522        }
523        if let Some(leaf) = value.get("leaf") {
524            LogLeaf::from_value(leaf)?;
525        }
526        Ok(inclusion)
527    }
528
529    /// §9.1 step 4 — binding checks: `tree_size` equals
530    /// `log_checkpoint.tree_size`, `log_id` equals
531    /// `log_checkpoint.log_id`, and `leaf_index < tree_size`.
532    pub fn cross_check_binding(&self) -> Result<(), AcdpError> {
533        if self.tree_size != self.log_checkpoint.tree_size {
534            return Err(AcdpError::InvalidLogProof(format!(
535                "log_inclusion tree_size {} ≠ log_checkpoint.tree_size {} \
536                 (RFC-ACDP-0012 §9.1 step 4)",
537                self.tree_size, self.log_checkpoint.tree_size
538            )));
539        }
540        if self.log_id != self.log_checkpoint.log_id {
541            return Err(AcdpError::InvalidLogProof(format!(
542                "log_inclusion log_id '{}' ≠ log_checkpoint.log_id '{}' \
543                 (RFC-ACDP-0012 §9.1 step 4)",
544                self.log_id, self.log_checkpoint.log_id
545            )));
546        }
547        if self.leaf_index >= self.tree_size {
548            return Err(AcdpError::InvalidLogProof(format!(
549                "log_inclusion leaf_index {} is not < tree_size {} (RFC-ACDP-0012 §9.1 step 4)",
550                self.leaf_index, self.tree_size
551            )));
552        }
553        Ok(())
554    }
555
556    /// §9.1 steps 4–6 — the pure arithmetic half of inclusion
557    /// verification: binding checks, then fold the audit path over
558    /// `leaf_hash` (the §5.1 hash of the *independently reconstructed*
559    /// leaf — never an echoed one) and compare against the checkpoint's
560    /// `root_hash`. Checkpoint signature verification (§9.3 step 2) is
561    /// separate — see [`LogCheckpoint::verify_signature_with_key`] and
562    /// the `client` feature's `verify_log_inclusion_value`.
563    pub fn verify_leaf_hash(&self, leaf_hash: &[u8; 32]) -> Result<(), AcdpError> {
564        self.cross_check_binding()?;
565        let path = self
566            .inclusion_path
567            .iter()
568            .map(|p| decode_sha256_hex(p))
569            .collect::<Result<Vec<_>, _>>()?;
570        let root = self.log_checkpoint.root_hash_bytes()?;
571        if !acdp_crypto::merkle::verify_inclusion(
572            leaf_hash,
573            self.leaf_index,
574            self.tree_size,
575            &path,
576            &root,
577        ) {
578            return Err(AcdpError::InvalidLogProof(format!(
579                "inclusion_path for leaf_index {} does not fold to the checkpoint root_hash \
580                 '{}' at tree_size {} (RFC-ACDP-0012 §9.1 step 6)",
581                self.leaf_index, self.log_checkpoint.root_hash, self.tree_size
582            )));
583        }
584        Ok(())
585    }
586
587    /// Convenience: [`Self::verify_leaf_hash`] over a reconstructed
588    /// [`LogLeaf`] (§9.1 steps 1–2 + 4–6). Pass the leaf built from
589    /// *verified* material — never the echoed `leaf` member.
590    pub fn verify_reconstructed_leaf(&self, leaf: &LogLeaf) -> Result<(), AcdpError> {
591        self.verify_leaf_hash(&leaf.leaf_hash()?)
592    }
593}
594
595// ── Consistency proof ────────────────────────────────────────────────────────
596
597/// A consistency-mode `GET /log/proof` response (RFC-ACDP-0012 §8.2):
598/// the RFC 6962 §2.1.2 proof `PROOF(first, D[second])` that the tree at
599/// `first_tree_size` is a prefix of the tree at `second_tree_size` —
600/// the history-rewrite detector. The verifier supplies its own
601/// *retained* root for `first_tree_size` (that retained root is the
602/// whole point, §9.2).
603///
604/// Distinct shape from [`LogInclusion`] (the schema `$comment` is
605/// explicit); closed for the same reason — arithmetic evidence has no
606/// extension surface.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608#[serde(deny_unknown_fields)]
609pub struct LogConsistencyProof {
610    /// The log instantiation. Consistency proofs exist only within one
611    /// `log_id` (§7.4).
612    pub log_id: String,
613    /// The earlier (retained) tree size.
614    pub first_tree_size: u64,
615    /// The later tree size. MUST equal `log_checkpoint.tree_size`.
616    pub second_tree_size: u64,
617    /// `PROOF(first, D[second])`, each element `"sha256:<hex>"`; empty
618    /// when `first == second`.
619    pub consistency_path: Vec<String>,
620    /// A checkpoint at `second_tree_size` (§8.2), verified per §9.3.
621    pub log_checkpoint: LogCheckpoint,
622}
623
624impl LogConsistencyProof {
625    /// Parse a consistency proof from a `GET /log/proof`
626    /// consistency-mode response, enforcing the closed schema and the
627    /// embedded checkpoint's §9.3 step 1 form.
628    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
629        let proof = Self::deserialize(value).map_err(|e| {
630            AcdpError::InvalidLogProof(format!("consistency proof does not parse: {e}"))
631        })?;
632        if let Some(cp) = value.get("log_checkpoint") {
633            LogCheckpoint::from_value(cp)?;
634        }
635        Ok(proof)
636    }
637
638    /// §9.2 — verify the proof between the verifier's **retained**
639    /// earlier root (`first_root_hash`, wire form) and the embedded
640    /// later checkpoint's root, plus the shape bindings (`log_id`s
641    /// equal, `second_tree_size == log_checkpoint.tree_size`).
642    /// Checkpoint signature verification (§9.3) is separate.
643    ///
644    /// A failure here between two *signature-valid* checkpoints of one
645    /// `log_id` is not a soft error: it is cryptographic evidence that
646    /// the registry rewrote logged history, and consumers SHOULD retain
647    /// both checkpoints and the failing path as evidence (§9.2, §15).
648    pub fn verify_against_first_root(&self, first_root_hash: &str) -> Result<(), AcdpError> {
649        if self.log_id != self.log_checkpoint.log_id {
650            return Err(AcdpError::InvalidLogProof(format!(
651                "consistency proof log_id '{}' ≠ log_checkpoint.log_id '{}' \
652                 (consistency proofs exist only within one log_id, RFC-ACDP-0012 §7.4)",
653                self.log_id, self.log_checkpoint.log_id
654            )));
655        }
656        if self.second_tree_size != self.log_checkpoint.tree_size {
657            return Err(AcdpError::InvalidLogProof(format!(
658                "consistency proof second_tree_size {} ≠ log_checkpoint.tree_size {} \
659                 (RFC-ACDP-0012 §8.2)",
660                self.second_tree_size, self.log_checkpoint.tree_size
661            )));
662        }
663        let first_root = decode_sha256_hex(first_root_hash)?;
664        let second_root = self.log_checkpoint.root_hash_bytes()?;
665        let path = self
666            .consistency_path
667            .iter()
668            .map(|p| decode_sha256_hex(p))
669            .collect::<Result<Vec<_>, _>>()?;
670        if !acdp_crypto::merkle::verify_consistency(
671            self.first_tree_size,
672            self.second_tree_size,
673            &path,
674            &first_root,
675            &second_root,
676        ) {
677            return Err(AcdpError::InvalidLogProof(format!(
678                "consistency_path does not prove the tree at size {} is a prefix of the tree \
679                 at size {} — evidence of a logged-history rewrite; retain both checkpoints \
680                 and this path (RFC-ACDP-0012 §9.2)",
681                self.first_tree_size, self.second_tree_size
682            )));
683        }
684        Ok(())
685    }
686}
687
688// ── Checkpoint minting (registry side) ───────────────────────────────────────
689
690impl ReceiptSigner {
691    /// Mint a signed **log checkpoint** (RFC-ACDP-0012 §6): the
692    /// registry's commitment that, as of `timestamp` (truncated here to
693    /// milliseconds), the log `log_id` has `tree_size` leaves and
694    /// Merkle root `root_hash`.
695    ///
696    /// Signs with the same receipt signing key as
697    /// [`ReceiptSigner::mint`] — the log introduces **no new key role**
698    /// (§2, §6); `checkpoint_version` inside the preimage is what
699    /// domain-separates checkpoints from both receipt kinds.
700    ///
701    /// Refuses an internally inconsistent checkpoint: a `log_id` whose
702    /// registry DID is not this signer's, a malformed `log_id`
703    /// instance, or a malformed `root_hash`.
704    pub fn mint_log_checkpoint(
705        &self,
706        log_id: &str,
707        tree_size: u64,
708        root_hash: &str,
709        timestamp: DateTime<Utc>,
710    ) -> Result<LogCheckpoint, AcdpError> {
711        let (did, _instance) = parse_log_id(log_id)?;
712        if did != self.registry_did() {
713            return Err(AcdpError::SchemaViolation(format!(
714                "cannot mint a checkpoint for log_id '{log_id}' under registry_did '{}' \
715                 (RFC-ACDP-0012 §6: the log_id embeds the registry's own DID)",
716                self.registry_did()
717            )));
718        }
719        decode_sha256_hex(root_hash)?;
720        let mut checkpoint = LogCheckpoint {
721            checkpoint_version: LOG_CHECKPOINT_VERSION.to_string(),
722            log_id: log_id.to_string(),
723            tree_size,
724            root_hash: root_hash.to_string(),
725            timestamp: acdp_primitives::time::trunc_ms(timestamp),
726            signature: Signature {
727                algorithm: self.signing_key().algorithm().into(),
728                key_id: self.key_id().to_string(),
729                value: String::new(), // filled below
730            },
731        };
732        let hash = checkpoint.preimage_hash()?;
733        let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
734        checkpoint.signature.algorithm = algorithm.into();
735        checkpoint.signature.value = value;
736        Ok(checkpoint)
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use acdp_crypto::SigningKey;
744
745    const REGISTRY_DID: &str = "did:web:registry.example.com";
746    const LOG_ID: &str = "did:web:registry.example.com/log/1";
747
748    fn signer() -> ReceiptSigner {
749        ReceiptSigner::new(
750            SigningKey::from_bytes(&[0x11u8; 32]),
751            REGISTRY_DID,
752            format!("{REGISTRY_DID}#receipt-key-1"),
753        )
754        .unwrap()
755    }
756
757    fn registry_pub() -> [u8; 32] {
758        SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
759    }
760
761    fn leaf(i: u8) -> LogLeaf {
762        let ctx_id =
763            format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
764        LogLeaf {
765            leaf_version: LOG_LEAF_VERSION.into(),
766            lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
767            ctx_id: CtxId(ctx_id),
768            origin_registry: "registry.example.com".into(),
769            created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
770                .unwrap()
771                .with_timezone(&Utc),
772            content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
773            key_fingerprint: format!("sha256:{}", "c".repeat(64)),
774            receipt_hash: format!("sha256:{}", "d".repeat(64)),
775        }
776    }
777
778    #[test]
779    fn leaf_closed_schema_and_version_enforced() {
780        let l = leaf(1);
781        let wire = serde_json::to_value(&l).unwrap();
782        assert_eq!(LogLeaf::from_value(&wire).unwrap(), l);
783
784        let mut extra = wire.clone();
785        extra
786            .as_object_mut()
787            .unwrap()
788            .insert("note".into(), serde_json::json!("x"));
789        assert!(matches!(
790            LogLeaf::from_value(&extra).unwrap_err(),
791            AcdpError::InvalidLogProof(_)
792        ));
793
794        let mut wrong = wire.clone();
795        wrong["leaf_version"] = serde_json::json!("acdp-log-leaf/2");
796        assert!(LogLeaf::from_value(&wrong).is_err());
797
798        let mut bad_ts = wire.clone();
799        bad_ts["created_at"] = serde_json::json!("2026-07-01T01:00:00Z");
800        assert!(LogLeaf::from_value(&bad_ts).is_err());
801    }
802
803    #[test]
804    fn checkpoint_mint_verify_round_trip() {
805        let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
806        let cp = signer()
807            .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
808            .unwrap();
809        cp.verify_signature_with_key(Some(&registry_pub()), None)
810            .expect("freshly minted checkpoint must verify");
811        let wire = serde_json::to_value(&cp).unwrap();
812        let parsed = LogCheckpoint::from_value(&wire).unwrap();
813        let raw_hash = LogCheckpoint::preimage_hash_of_value(&wire).unwrap();
814        assert_eq!(raw_hash, cp.preimage_hash().unwrap());
815        parsed
816            .verify_signature_against_hash(&raw_hash, Some(&registry_pub()), None)
817            .unwrap();
818        parsed
819            .cross_check_registry_binding("registry.example.com", REGISTRY_DID)
820            .unwrap();
821        assert!(parsed
822            .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
823            .is_err());
824        parsed
825            .check_timestamp_skew(Utc::now(), chrono::Duration::seconds(120))
826            .unwrap();
827
828        // log-004-style: tampering root_hash after signing fails.
829        let mut tampered = wire.clone();
830        tampered["root_hash"] = serde_json::json!(format!("sha256:{}", "f".repeat(64)));
831        let t = LogCheckpoint::from_value(&tampered).unwrap();
832        let t_hash = LogCheckpoint::preimage_hash_of_value(&tampered).unwrap();
833        let err = t
834            .verify_signature_against_hash(&t_hash, Some(&registry_pub()), None)
835            .unwrap_err();
836        assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
837    }
838
839    #[test]
840    fn checkpoint_closed_schema_and_domain_separation() {
841        let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
842        let cp = signer()
843            .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
844            .unwrap();
845        let mut wire = serde_json::to_value(&cp).unwrap();
846        wire.as_object_mut()
847            .unwrap()
848            .insert("witnesses".into(), serde_json::json!([]));
849        assert!(matches!(
850            LogCheckpoint::from_value(&wire).unwrap_err(),
851            AcdpError::InvalidLogProof(_)
852        ));
853
854        let mut wire = serde_json::to_value(&cp).unwrap();
855        wire["checkpoint_version"] = serde_json::json!("acdp-lhr/1");
856        assert!(LogCheckpoint::from_value(&wire).is_err());
857
858        // A checkpoint never parses as a receipt and vice versa.
859        let wire = serde_json::to_value(&cp).unwrap();
860        assert!(crate::receipt::LineageHeadReceipt::from_value(&wire).is_err());
861
862        // Foreign log_id refused at mint; malformed instance refused.
863        assert!(signer()
864            .mint_log_checkpoint("did:web:evil.example.com/log/1", 0, &root, Utc::now())
865            .is_err());
866        assert!(signer()
867            .mint_log_checkpoint(
868                "did:web:registry.example.com/log/UPPER",
869                0,
870                &root,
871                Utc::now()
872            )
873            .is_err());
874        assert!(signer()
875            .mint_log_checkpoint(LOG_ID, 0, "sha256:short", Utc::now())
876            .is_err());
877    }
878
879    /// Inclusion + consistency round trip over minted leaves, plus the
880    /// log-002-style tampered-path rejection.
881    #[test]
882    fn inclusion_and_consistency_round_trip() {
883        let leaves: Vec<LogLeaf> = (0..5).map(leaf).collect();
884        let hashes: Vec<[u8; 32]> = leaves.iter().map(|l| l.leaf_hash().unwrap()).collect();
885        let root = acdp_crypto::merkle_tree_hash(&hashes);
886        let cp = signer()
887            .mint_log_checkpoint(LOG_ID, 5, &encode_sha256_hex(&root), Utc::now())
888            .unwrap();
889
890        for (i, l) in leaves.iter().enumerate() {
891            let path = acdp_crypto::inclusion_path(i, &hashes).unwrap();
892            let inclusion = LogInclusion {
893                log_id: LOG_ID.into(),
894                leaf_index: i as u64,
895                tree_size: 5,
896                inclusion_path: path.iter().map(encode_sha256_hex).collect(),
897                log_checkpoint: cp.clone(),
898                leaf: None,
899            };
900            inclusion.verify_reconstructed_leaf(l).unwrap();
901            // Wire round trip through the closed parse.
902            let wire = serde_json::to_value(&inclusion).unwrap();
903            assert!(wire.get("leaf").is_none(), "absent, never null");
904            LogInclusion::from_value(&wire)
905                .unwrap()
906                .verify_leaf_hash(&hashes[i])
907                .unwrap();
908
909            // Tampered path element → §9.1 step 6 failure.
910            if !inclusion.inclusion_path.is_empty() {
911                let mut bad = inclusion.clone();
912                let mut raw = decode_sha256_hex(&bad.inclusion_path[0]).unwrap();
913                raw[0] ^= 1;
914                bad.inclusion_path[0] = encode_sha256_hex(&raw);
915                let err = bad.verify_reconstructed_leaf(l).unwrap_err();
916                assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
917            }
918        }
919
920        // Binding failures (§9.1 step 4).
921        let path = acdp_crypto::inclusion_path(0, &hashes).unwrap();
922        let mk = |leaf_index, tree_size, log_id: &str| LogInclusion {
923            log_id: log_id.into(),
924            leaf_index,
925            tree_size,
926            inclusion_path: path.iter().map(encode_sha256_hex).collect(),
927            log_checkpoint: cp.clone(),
928            leaf: None,
929        };
930        assert!(mk(0, 4, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); // ≠ checkpoint size
931        assert!(mk(5, 5, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); // index ≥ size
932        assert!(mk(0, 5, "did:web:registry.example.com/log/2")
933            .verify_leaf_hash(&hashes[0])
934            .is_err()); // log_id mismatch
935
936        // Consistency 3 → 5.
937        let first_root = acdp_crypto::merkle_tree_hash(&hashes[..3]);
938        let proof = LogConsistencyProof {
939            log_id: LOG_ID.into(),
940            first_tree_size: 3,
941            second_tree_size: 5,
942            consistency_path: acdp_crypto::consistency_proof(3, &hashes)
943                .unwrap()
944                .iter()
945                .map(encode_sha256_hex)
946                .collect(),
947            log_checkpoint: cp.clone(),
948        };
949        proof
950            .verify_against_first_root(&encode_sha256_hex(&first_root))
951            .unwrap();
952        // A different retained root → history-rewrite evidence.
953        let err = proof
954            .verify_against_first_root(&format!("sha256:{}", "e".repeat(64)))
955            .unwrap_err();
956        assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
957    }
958
959    #[test]
960    fn log_id_parsing() {
961        assert_eq!(
962            parse_log_id(LOG_ID).unwrap(),
963            ("did:web:registry.example.com", "1")
964        );
965        for bad in [
966            "did:web:registry.example.com",                       // no /log/
967            "did:key:z6Mk/log/1",                                 // not did:web
968            "did:web:registry.example.com/log/",                  // empty instance
969            "did:web:registry.example.com/log/UP",                // uppercase instance
970            &format!("did:web:r.example/log/{}", "a".repeat(33)), // too long
971        ] {
972            assert!(parse_log_id(bad).is_err(), "{bad} must be rejected");
973        }
974    }
975}