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.timestamp.timestamp_subsec_nanos() % 1_000_000 != 0 {
435            return Err(AcdpError::InvalidLogProof(
436                "log_checkpoint timestamp is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
437            ));
438        }
439        if self.timestamp > now + max_clock_skew {
440            return Err(AcdpError::InvalidLogProof(format!(
441                "log_checkpoint timestamp '{}' is in the future beyond the {}s clock-skew \
442                 allowance (consumer clock '{}') (RFC-ACDP-0012 §9.3 step 4)",
443                self.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
444                max_clock_skew.num_seconds(),
445                now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
446            )));
447        }
448        Ok(())
449    }
450
451    /// The checkpoint's age at `now` — the input to the consumer's §7.2
452    /// freshness policy (RECOMMENDED maximum: 300 seconds).
453    pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
454        now - self.timestamp
455    }
456}
457
458// ── Inclusion proof ──────────────────────────────────────────────────────────
459
460/// An inclusion proof that one leaf is committed by a checkpointed
461/// transparency-log tree (RFC-ACDP-0012 §8.2, §9.1): the RFC 6962
462/// §2.1.1 audit path for `leaf_index` at `tree_size`, together with the
463/// signed checkpoint it verifies against.
464///
465/// Returned by `GET /log/proof` (inclusion mode) and OPTIONALLY carried
466/// as the top-level `log_inclusion` member of the full-retrieval
467/// envelope (§10 — a **sibling** of `registry_receipt`, never a member
468/// of it). CLOSED schema: a proof is arithmetic evidence with no
469/// extension surface.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct LogInclusion {
473    /// The log instantiation this proof belongs to. MUST equal
474    /// `log_checkpoint.log_id` (§9.1 step 4).
475    pub log_id: String,
476    /// The 0-based append-order position of the leaf. MUST be
477    /// `< tree_size`.
478    pub leaf_index: u64,
479    /// The tree size the proof is computed against. MUST equal
480    /// `log_checkpoint.tree_size` (§9.1 step 4).
481    pub tree_size: u64,
482    /// The RFC 6962 §2.1.1 audit path `PATH(leaf_index, D[tree_size])`,
483    /// ordered from the lowest tree level upward, each element
484    /// `"sha256:<hex>"`. May be empty (`tree_size` 1).
485    pub inclusion_path: Vec<String>,
486    /// The signed checkpoint this proof verifies against (§9.3).
487    pub log_checkpoint: LogCheckpoint,
488    /// OPTIONAL convenience echo of the leaf, present on inclusion-mode
489    /// `GET /log/proof` responses only when the requester is authorized
490    /// to retrieve the context (§8.2), and absent from the
491    /// retrieval-envelope `log_inclusion` member (§10). Verifiers MUST
492    /// NOT trust it — the leaf is independently reconstructed from
493    /// verified body and receipt material (§9.1 step 1).
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub leaf: Option<LogLeaf>,
496}
497
498impl LogInclusion {
499    /// Parse an inclusion proof from the JSON value carried in
500    /// [`crate::body::FullContext::log_inclusion`] (or a
501    /// `GET /log/proof` inclusion-mode response), enforcing the closed
502    /// schema, the embedded checkpoint's §9.3 step 1 form, and
503    /// `tree_size ≥ 1`.
504    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
505        let inclusion = Self::deserialize(value).map_err(|e| {
506            AcdpError::InvalidLogProof(format!("log_inclusion does not parse: {e}"))
507        })?;
508        if inclusion.tree_size < 1 {
509            return Err(AcdpError::InvalidLogProof(
510                "log_inclusion tree_size must be >= 1 (an empty tree has no members)".into(),
511            ));
512        }
513        // The embedded checkpoint's schema-closed invariants (§9.3
514        // step 1) — reparse from the raw member so the RAW timestamp
515        // byte form is checked too.
516        if let Some(cp) = value.get("log_checkpoint") {
517            LogCheckpoint::from_value(cp)?;
518        }
519        if let Some(leaf) = value.get("leaf") {
520            LogLeaf::from_value(leaf)?;
521        }
522        Ok(inclusion)
523    }
524
525    /// §9.1 step 4 — binding checks: `tree_size` equals
526    /// `log_checkpoint.tree_size`, `log_id` equals
527    /// `log_checkpoint.log_id`, and `leaf_index < tree_size`.
528    pub fn cross_check_binding(&self) -> Result<(), AcdpError> {
529        if self.tree_size != self.log_checkpoint.tree_size {
530            return Err(AcdpError::InvalidLogProof(format!(
531                "log_inclusion tree_size {} ≠ log_checkpoint.tree_size {} \
532                 (RFC-ACDP-0012 §9.1 step 4)",
533                self.tree_size, self.log_checkpoint.tree_size
534            )));
535        }
536        if self.log_id != self.log_checkpoint.log_id {
537            return Err(AcdpError::InvalidLogProof(format!(
538                "log_inclusion log_id '{}' ≠ log_checkpoint.log_id '{}' \
539                 (RFC-ACDP-0012 §9.1 step 4)",
540                self.log_id, self.log_checkpoint.log_id
541            )));
542        }
543        if self.leaf_index >= self.tree_size {
544            return Err(AcdpError::InvalidLogProof(format!(
545                "log_inclusion leaf_index {} is not < tree_size {} (RFC-ACDP-0012 §9.1 step 4)",
546                self.leaf_index, self.tree_size
547            )));
548        }
549        Ok(())
550    }
551
552    /// §9.1 steps 4–6 — the pure arithmetic half of inclusion
553    /// verification: binding checks, then fold the audit path over
554    /// `leaf_hash` (the §5.1 hash of the *independently reconstructed*
555    /// leaf — never an echoed one) and compare against the checkpoint's
556    /// `root_hash`. Checkpoint signature verification (§9.3 step 2) is
557    /// separate — see [`LogCheckpoint::verify_signature_with_key`] and
558    /// the `client` feature's `verify_log_inclusion_value`.
559    pub fn verify_leaf_hash(&self, leaf_hash: &[u8; 32]) -> Result<(), AcdpError> {
560        self.cross_check_binding()?;
561        let path = self
562            .inclusion_path
563            .iter()
564            .map(|p| decode_sha256_hex(p))
565            .collect::<Result<Vec<_>, _>>()?;
566        let root = self.log_checkpoint.root_hash_bytes()?;
567        if !acdp_crypto::merkle::verify_inclusion(
568            leaf_hash,
569            self.leaf_index,
570            self.tree_size,
571            &path,
572            &root,
573        ) {
574            return Err(AcdpError::InvalidLogProof(format!(
575                "inclusion_path for leaf_index {} does not fold to the checkpoint root_hash \
576                 '{}' at tree_size {} (RFC-ACDP-0012 §9.1 step 6)",
577                self.leaf_index, self.log_checkpoint.root_hash, self.tree_size
578            )));
579        }
580        Ok(())
581    }
582
583    /// Convenience: [`Self::verify_leaf_hash`] over a reconstructed
584    /// [`LogLeaf`] (§9.1 steps 1–2 + 4–6). Pass the leaf built from
585    /// *verified* material — never the echoed `leaf` member.
586    pub fn verify_reconstructed_leaf(&self, leaf: &LogLeaf) -> Result<(), AcdpError> {
587        self.verify_leaf_hash(&leaf.leaf_hash()?)
588    }
589}
590
591// ── Consistency proof ────────────────────────────────────────────────────────
592
593/// A consistency-mode `GET /log/proof` response (RFC-ACDP-0012 §8.2):
594/// the RFC 6962 §2.1.2 proof `PROOF(first, D[second])` that the tree at
595/// `first_tree_size` is a prefix of the tree at `second_tree_size` —
596/// the history-rewrite detector. The verifier supplies its own
597/// *retained* root for `first_tree_size` (that retained root is the
598/// whole point, §9.2).
599///
600/// Distinct shape from [`LogInclusion`] (the schema `$comment` is
601/// explicit); closed for the same reason — arithmetic evidence has no
602/// extension surface.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(deny_unknown_fields)]
605pub struct LogConsistencyProof {
606    /// The log instantiation. Consistency proofs exist only within one
607    /// `log_id` (§7.4).
608    pub log_id: String,
609    /// The earlier (retained) tree size.
610    pub first_tree_size: u64,
611    /// The later tree size. MUST equal `log_checkpoint.tree_size`.
612    pub second_tree_size: u64,
613    /// `PROOF(first, D[second])`, each element `"sha256:<hex>"`; empty
614    /// when `first == second`.
615    pub consistency_path: Vec<String>,
616    /// A checkpoint at `second_tree_size` (§8.2), verified per §9.3.
617    pub log_checkpoint: LogCheckpoint,
618}
619
620impl LogConsistencyProof {
621    /// Parse a consistency proof from a `GET /log/proof`
622    /// consistency-mode response, enforcing the closed schema and the
623    /// embedded checkpoint's §9.3 step 1 form.
624    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
625        let proof = Self::deserialize(value).map_err(|e| {
626            AcdpError::InvalidLogProof(format!("consistency proof does not parse: {e}"))
627        })?;
628        if let Some(cp) = value.get("log_checkpoint") {
629            LogCheckpoint::from_value(cp)?;
630        }
631        Ok(proof)
632    }
633
634    /// §9.2 — verify the proof between the verifier's **retained**
635    /// earlier root (`first_root_hash`, wire form) and the embedded
636    /// later checkpoint's root, plus the shape bindings (`log_id`s
637    /// equal, `second_tree_size == log_checkpoint.tree_size`).
638    /// Checkpoint signature verification (§9.3) is separate.
639    ///
640    /// A failure here between two *signature-valid* checkpoints of one
641    /// `log_id` is not a soft error: it is cryptographic evidence that
642    /// the registry rewrote logged history, and consumers SHOULD retain
643    /// both checkpoints and the failing path as evidence (§9.2, §15).
644    pub fn verify_against_first_root(&self, first_root_hash: &str) -> Result<(), AcdpError> {
645        if self.log_id != self.log_checkpoint.log_id {
646            return Err(AcdpError::InvalidLogProof(format!(
647                "consistency proof log_id '{}' ≠ log_checkpoint.log_id '{}' \
648                 (consistency proofs exist only within one log_id, RFC-ACDP-0012 §7.4)",
649                self.log_id, self.log_checkpoint.log_id
650            )));
651        }
652        if self.second_tree_size != self.log_checkpoint.tree_size {
653            return Err(AcdpError::InvalidLogProof(format!(
654                "consistency proof second_tree_size {} ≠ log_checkpoint.tree_size {} \
655                 (RFC-ACDP-0012 §8.2)",
656                self.second_tree_size, self.log_checkpoint.tree_size
657            )));
658        }
659        let first_root = decode_sha256_hex(first_root_hash)?;
660        let second_root = self.log_checkpoint.root_hash_bytes()?;
661        let path = self
662            .consistency_path
663            .iter()
664            .map(|p| decode_sha256_hex(p))
665            .collect::<Result<Vec<_>, _>>()?;
666        if !acdp_crypto::merkle::verify_consistency(
667            self.first_tree_size,
668            self.second_tree_size,
669            &path,
670            &first_root,
671            &second_root,
672        ) {
673            return Err(AcdpError::InvalidLogProof(format!(
674                "consistency_path does not prove the tree at size {} is a prefix of the tree \
675                 at size {} — evidence of a logged-history rewrite; retain both checkpoints \
676                 and this path (RFC-ACDP-0012 §9.2)",
677                self.first_tree_size, self.second_tree_size
678            )));
679        }
680        Ok(())
681    }
682}
683
684// ── Checkpoint minting (registry side) ───────────────────────────────────────
685
686impl ReceiptSigner {
687    /// Mint a signed **log checkpoint** (RFC-ACDP-0012 §6): the
688    /// registry's commitment that, as of `timestamp` (truncated here to
689    /// milliseconds), the log `log_id` has `tree_size` leaves and
690    /// Merkle root `root_hash`.
691    ///
692    /// Signs with the same receipt signing key as
693    /// [`ReceiptSigner::mint`] — the log introduces **no new key role**
694    /// (§2, §6); `checkpoint_version` inside the preimage is what
695    /// domain-separates checkpoints from both receipt kinds.
696    ///
697    /// Refuses an internally inconsistent checkpoint: a `log_id` whose
698    /// registry DID is not this signer's, a malformed `log_id`
699    /// instance, or a malformed `root_hash`.
700    pub fn mint_log_checkpoint(
701        &self,
702        log_id: &str,
703        tree_size: u64,
704        root_hash: &str,
705        timestamp: DateTime<Utc>,
706    ) -> Result<LogCheckpoint, AcdpError> {
707        let (did, _instance) = parse_log_id(log_id)?;
708        if did != self.registry_did() {
709            return Err(AcdpError::SchemaViolation(format!(
710                "cannot mint a checkpoint for log_id '{log_id}' under registry_did '{}' \
711                 (RFC-ACDP-0012 §6: the log_id embeds the registry's own DID)",
712                self.registry_did()
713            )));
714        }
715        decode_sha256_hex(root_hash)?;
716        let mut checkpoint = LogCheckpoint {
717            checkpoint_version: LOG_CHECKPOINT_VERSION.to_string(),
718            log_id: log_id.to_string(),
719            tree_size,
720            root_hash: root_hash.to_string(),
721            timestamp: acdp_primitives::time::trunc_ms(timestamp),
722            signature: Signature {
723                algorithm: self.signing_key().algorithm().into(),
724                key_id: self.key_id().to_string(),
725                value: String::new(), // filled below
726            },
727        };
728        let hash = checkpoint.preimage_hash()?;
729        let (algorithm, value) = self.signing_key().sign_content_hash(&hash);
730        checkpoint.signature.algorithm = algorithm.into();
731        checkpoint.signature.value = value;
732        Ok(checkpoint)
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use acdp_crypto::SigningKey;
740
741    const REGISTRY_DID: &str = "did:web:registry.example.com";
742    const LOG_ID: &str = "did:web:registry.example.com/log/1";
743
744    fn signer() -> ReceiptSigner {
745        ReceiptSigner::new(
746            SigningKey::from_bytes(&[0x11u8; 32]),
747            REGISTRY_DID,
748            format!("{REGISTRY_DID}#receipt-key-1"),
749        )
750        .unwrap()
751    }
752
753    fn registry_pub() -> [u8; 32] {
754        SigningKey::from_bytes(&[0x11u8; 32]).verifying_key_bytes()
755    }
756
757    fn leaf(i: u8) -> LogLeaf {
758        let ctx_id =
759            format!("acdp://registry.example.com/00000000-0000-4000-8000-0000000000{i:02}");
760        LogLeaf {
761            leaf_version: LOG_LEAF_VERSION.into(),
762            lineage_id: acdp_crypto::derive_lineage_id(&CtxId(ctx_id.clone())),
763            ctx_id: CtxId(ctx_id),
764            origin_registry: "registry.example.com".into(),
765            created_at: chrono::DateTime::parse_from_rfc3339("2026-07-01T01:00:00.123Z")
766                .unwrap()
767                .with_timezone(&Utc),
768            content_hash: ContentHash(format!("sha256:{}", "b".repeat(64))),
769            key_fingerprint: format!("sha256:{}", "c".repeat(64)),
770            receipt_hash: format!("sha256:{}", "d".repeat(64)),
771        }
772    }
773
774    #[test]
775    fn leaf_closed_schema_and_version_enforced() {
776        let l = leaf(1);
777        let wire = serde_json::to_value(&l).unwrap();
778        assert_eq!(LogLeaf::from_value(&wire).unwrap(), l);
779
780        let mut extra = wire.clone();
781        extra
782            .as_object_mut()
783            .unwrap()
784            .insert("note".into(), serde_json::json!("x"));
785        assert!(matches!(
786            LogLeaf::from_value(&extra).unwrap_err(),
787            AcdpError::InvalidLogProof(_)
788        ));
789
790        let mut wrong = wire.clone();
791        wrong["leaf_version"] = serde_json::json!("acdp-log-leaf/2");
792        assert!(LogLeaf::from_value(&wrong).is_err());
793
794        let mut bad_ts = wire.clone();
795        bad_ts["created_at"] = serde_json::json!("2026-07-01T01:00:00Z");
796        assert!(LogLeaf::from_value(&bad_ts).is_err());
797    }
798
799    #[test]
800    fn checkpoint_mint_verify_round_trip() {
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        cp.verify_signature_with_key(Some(&registry_pub()), None)
806            .expect("freshly minted checkpoint must verify");
807        let wire = serde_json::to_value(&cp).unwrap();
808        let parsed = LogCheckpoint::from_value(&wire).unwrap();
809        let raw_hash = LogCheckpoint::preimage_hash_of_value(&wire).unwrap();
810        assert_eq!(raw_hash, cp.preimage_hash().unwrap());
811        parsed
812            .verify_signature_against_hash(&raw_hash, Some(&registry_pub()), None)
813            .unwrap();
814        parsed
815            .cross_check_registry_binding("registry.example.com", REGISTRY_DID)
816            .unwrap();
817        assert!(parsed
818            .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
819            .is_err());
820        parsed
821            .check_timestamp_skew(Utc::now(), chrono::Duration::seconds(120))
822            .unwrap();
823
824        // log-004-style: tampering root_hash after signing fails.
825        let mut tampered = wire.clone();
826        tampered["root_hash"] = serde_json::json!(format!("sha256:{}", "f".repeat(64)));
827        let t = LogCheckpoint::from_value(&tampered).unwrap();
828        let t_hash = LogCheckpoint::preimage_hash_of_value(&tampered).unwrap();
829        let err = t
830            .verify_signature_against_hash(&t_hash, Some(&registry_pub()), None)
831            .unwrap_err();
832        assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
833    }
834
835    #[test]
836    fn checkpoint_closed_schema_and_domain_separation() {
837        let root = encode_sha256_hex(&acdp_crypto::merkle_tree_hash(&[]));
838        let cp = signer()
839            .mint_log_checkpoint(LOG_ID, 0, &root, Utc::now())
840            .unwrap();
841        let mut wire = serde_json::to_value(&cp).unwrap();
842        wire.as_object_mut()
843            .unwrap()
844            .insert("witnesses".into(), serde_json::json!([]));
845        assert!(matches!(
846            LogCheckpoint::from_value(&wire).unwrap_err(),
847            AcdpError::InvalidLogProof(_)
848        ));
849
850        let mut wire = serde_json::to_value(&cp).unwrap();
851        wire["checkpoint_version"] = serde_json::json!("acdp-lhr/1");
852        assert!(LogCheckpoint::from_value(&wire).is_err());
853
854        // A checkpoint never parses as a receipt and vice versa.
855        let wire = serde_json::to_value(&cp).unwrap();
856        assert!(crate::receipt::LineageHeadReceipt::from_value(&wire).is_err());
857
858        // Foreign log_id refused at mint; malformed instance refused.
859        assert!(signer()
860            .mint_log_checkpoint("did:web:evil.example.com/log/1", 0, &root, Utc::now())
861            .is_err());
862        assert!(signer()
863            .mint_log_checkpoint(
864                "did:web:registry.example.com/log/UPPER",
865                0,
866                &root,
867                Utc::now()
868            )
869            .is_err());
870        assert!(signer()
871            .mint_log_checkpoint(LOG_ID, 0, "sha256:short", Utc::now())
872            .is_err());
873    }
874
875    /// Inclusion + consistency round trip over minted leaves, plus the
876    /// log-002-style tampered-path rejection.
877    #[test]
878    fn inclusion_and_consistency_round_trip() {
879        let leaves: Vec<LogLeaf> = (0..5).map(leaf).collect();
880        let hashes: Vec<[u8; 32]> = leaves.iter().map(|l| l.leaf_hash().unwrap()).collect();
881        let root = acdp_crypto::merkle_tree_hash(&hashes);
882        let cp = signer()
883            .mint_log_checkpoint(LOG_ID, 5, &encode_sha256_hex(&root), Utc::now())
884            .unwrap();
885
886        for (i, l) in leaves.iter().enumerate() {
887            let path = acdp_crypto::inclusion_path(i, &hashes).unwrap();
888            let inclusion = LogInclusion {
889                log_id: LOG_ID.into(),
890                leaf_index: i as u64,
891                tree_size: 5,
892                inclusion_path: path.iter().map(encode_sha256_hex).collect(),
893                log_checkpoint: cp.clone(),
894                leaf: None,
895            };
896            inclusion.verify_reconstructed_leaf(l).unwrap();
897            // Wire round trip through the closed parse.
898            let wire = serde_json::to_value(&inclusion).unwrap();
899            assert!(wire.get("leaf").is_none(), "absent, never null");
900            LogInclusion::from_value(&wire)
901                .unwrap()
902                .verify_leaf_hash(&hashes[i])
903                .unwrap();
904
905            // Tampered path element → §9.1 step 6 failure.
906            if !inclusion.inclusion_path.is_empty() {
907                let mut bad = inclusion.clone();
908                let mut raw = decode_sha256_hex(&bad.inclusion_path[0]).unwrap();
909                raw[0] ^= 1;
910                bad.inclusion_path[0] = encode_sha256_hex(&raw);
911                let err = bad.verify_reconstructed_leaf(l).unwrap_err();
912                assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
913            }
914        }
915
916        // Binding failures (§9.1 step 4).
917        let path = acdp_crypto::inclusion_path(0, &hashes).unwrap();
918        let mk = |leaf_index, tree_size, log_id: &str| LogInclusion {
919            log_id: log_id.into(),
920            leaf_index,
921            tree_size,
922            inclusion_path: path.iter().map(encode_sha256_hex).collect(),
923            log_checkpoint: cp.clone(),
924            leaf: None,
925        };
926        assert!(mk(0, 4, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); // ≠ checkpoint size
927        assert!(mk(5, 5, LOG_ID).verify_leaf_hash(&hashes[0]).is_err()); // index ≥ size
928        assert!(mk(0, 5, "did:web:registry.example.com/log/2")
929            .verify_leaf_hash(&hashes[0])
930            .is_err()); // log_id mismatch
931
932        // Consistency 3 → 5.
933        let first_root = acdp_crypto::merkle_tree_hash(&hashes[..3]);
934        let proof = LogConsistencyProof {
935            log_id: LOG_ID.into(),
936            first_tree_size: 3,
937            second_tree_size: 5,
938            consistency_path: acdp_crypto::consistency_proof(3, &hashes)
939                .unwrap()
940                .iter()
941                .map(encode_sha256_hex)
942                .collect(),
943            log_checkpoint: cp.clone(),
944        };
945        proof
946            .verify_against_first_root(&encode_sha256_hex(&first_root))
947            .unwrap();
948        // A different retained root → history-rewrite evidence.
949        let err = proof
950            .verify_against_first_root(&format!("sha256:{}", "e".repeat(64)))
951            .unwrap_err();
952        assert!(matches!(err, AcdpError::InvalidLogProof(_)), "got {err:?}");
953    }
954
955    #[test]
956    fn log_id_parsing() {
957        assert_eq!(
958            parse_log_id(LOG_ID).unwrap(),
959            ("did:web:registry.example.com", "1")
960        );
961        for bad in [
962            "did:web:registry.example.com",                       // no /log/
963            "did:key:z6Mk/log/1",                                 // not did:web
964            "did:web:registry.example.com/log/",                  // empty instance
965            "did:web:registry.example.com/log/UP",                // uppercase instance
966            &format!("did:web:r.example/log/{}", "a".repeat(33)), // too long
967        ] {
968            assert!(parse_log_id(bad).is_err(), "{bad} must be rejected");
969        }
970    }
971}