Skip to main content

acdp_client/
log.rs

1//! Client-side transparency-log verification (ACDP 0.3, RFC-ACDP-0012
2//! §9): checkpoints (§9.3) and inclusion proofs (§9.1) against the
3//! registry's DID document.
4//!
5//! All failures map to [`AcdpError::InvalidLogProof`] — deliberately
6//! **not** `InvalidReceipt`: a proof failure indicts the *log* (tree
7//! membership, history consistency, checkpoint signature), never the
8//! body, receipt, or head-receipt verdicts, which are independent
9//! results and reported independently (RFC-ACDP-0012 §9.3, §11).
10//! Transport-level DID-resolution failures keep their
11//! transient/permanent classification so retry logic still works.
12
13use acdp_did::web::WebResolver;
14use acdp_primitives::error::AcdpError;
15use acdp_types::log::{LogCheckpoint, LogInclusion, LogLeaf};
16
17/// Verify a `log_checkpoint` value end-to-end per RFC-ACDP-0012 §9.3:
18///
19/// 1. **Schema-closed parse** — exact `checkpoint_version`
20///    (`"acdp-log/1"`), well-formed `log_id`/`root_hash`, canonical
21///    millisecond byte form of the RAW `timestamp`.
22/// 2. **Recompute and verify the signature** — JCS-recompute the
23///    preimage from the RAW wire JSON (minus `signature`), resolve
24///    `signature.key_id` from the registry's DID document (with the
25///    RFC-ACDP-0008 §4.8 SSRF protections the resolver applies), and
26///    verify over the ASCII bytes of the checkpoint hash. Key
27///    acceptance follows RFC-ACDP-0010 §9: the key is looked up in
28///    `verificationMethod` WITHOUT requiring `assertionMethod`
29///    membership, so retired receipt keys keep historical checkpoints
30///    verifiable.
31/// 3. **Registry binding** — the `registry_did` prefix of `log_id`
32///    equals `did:web:<serving_authority>` (the authority the
33///    checkpoint was actually fetched from) AND
34///    `capabilities.registry_did`; `signature.key_id` is a DID URL
35///    under that DID.
36/// 4. **Form** — `timestamp` millisecond-truncated and not in the
37///    future beyond `max_clock_skew` (RECOMMENDED 120 s). Staleness is
38///    consumer freshness policy (§7.2), evaluated separately via
39///    [`LogCheckpoint::age_at`].
40pub async fn verify_log_checkpoint_value(
41    value: &serde_json::Value,
42    serving_authority: &str,
43    capabilities_registry_did: &str,
44    max_clock_skew: chrono::Duration,
45    resolver: &WebResolver,
46) -> Result<LogCheckpoint, AcdpError> {
47    // §9.3 step 1: closed parse + form invariants.
48    let checkpoint = LogCheckpoint::from_value(value)?;
49
50    // §9.3 step 3 binding + step 4 form — pure checks before the
51    // network round-trip for the signature, exactly like
52    // `verify_receipt_value`.
53    checkpoint.cross_check_registry_binding(serving_authority, capabilities_registry_did)?;
54    checkpoint.check_timestamp_skew(chrono::Utc::now(), max_clock_skew)?;
55
56    // §9.3 step 2: resolve the registry's receipt key and verify over
57    // the RAW wire preimage (re-serializing the parsed struct could
58    // normalize byte details and falsely reject an honest checkpoint —
59    // and byte-comparing served values instead of recomputing is
60    // exactly the mistake fixture log-004 exists to catch).
61    let key_id = &checkpoint.signature.key_id;
62    let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
63        AcdpError::InvalidLogProof(format!(
64            "log_checkpoint signature.key_id '{key_id}' has no fragment"
65        ))
66    })?;
67    let doc = resolver.resolve(did_part).await?;
68    let method = doc.find_by_fragment(fragment).ok_or_else(|| {
69        AcdpError::InvalidLogProof(format!(
70            "registry DID document has no verification method '#{fragment}' — \
71             receipt keys (including retired ones) must remain in verificationMethod \
72             (RFC-ACDP-0010 §9)"
73        ))
74    })?;
75    let raw_hash = LogCheckpoint::preimage_hash_of_value(value)?;
76    match checkpoint.signature.algorithm.as_str() {
77        "ed25519" => {
78            let key = method.ed25519_public_key_bytes().map_err(|e| {
79                AcdpError::InvalidLogProof(format!("checkpoint key extraction: {e}"))
80            })?;
81            checkpoint.verify_signature_against_hash(&raw_hash, Some(&key), None)?;
82        }
83        "ecdsa-p256" => {
84            let key = method.ecdsa_p256_public_key_sec1().map_err(|e| {
85                AcdpError::InvalidLogProof(format!("checkpoint key extraction: {e}"))
86            })?;
87            checkpoint.verify_signature_against_hash(&raw_hash, None, Some(&key))?;
88        }
89        other => {
90            return Err(AcdpError::InvalidLogProof(format!(
91                "log_checkpoint signature algorithm '{other}' is not supported"
92            )));
93        }
94    }
95
96    Ok(checkpoint)
97}
98
99/// Verify a `log_inclusion` value end-to-end per RFC-ACDP-0012 §9.1
100/// (steps 2–6; step 1 — reconstructing the leaf from *verified*
101/// material — is the caller's, since only the caller holds the
102/// verified body, the recomputed `content_hash`, the resolved producer
103/// key fingerprint, and the RFC-ACDP-0010-verified receipt):
104///
105/// 2. **Leaf hash** — `SHA-256(0x00 ‖ JCS(leaf))` over the
106///    reconstructed leaf (§5.1). Any echoed `leaf` member in the proof
107///    is ignored, never substituted.
108/// 3. **Checkpoint** — the embedded `log_checkpoint` verified per §9.3
109///    ([`verify_log_checkpoint_value`]).
110/// 4. **Binding** — `tree_size` equals `log_checkpoint.tree_size`,
111///    `log_id`s equal, `leaf_index < tree_size`.
112/// 5. **Fold** the audit path (RFC 9162 §2.1.3.2).
113/// 6. **Compare** the folded root against `log_checkpoint.root_hash`.
114pub async fn verify_log_inclusion_value(
115    value: &serde_json::Value,
116    reconstructed_leaf: &LogLeaf,
117    serving_authority: &str,
118    capabilities_registry_did: &str,
119    max_clock_skew: chrono::Duration,
120    resolver: &WebResolver,
121) -> Result<LogInclusion, AcdpError> {
122    let inclusion = LogInclusion::from_value(value)?;
123
124    // §9.1 step 3: verify the embedded checkpoint per §9.3, over its
125    // RAW wire member.
126    let checkpoint_value = value.get("log_checkpoint").ok_or_else(|| {
127        AcdpError::InvalidLogProof("log_inclusion has no log_checkpoint member".into())
128    })?;
129    verify_log_checkpoint_value(
130        checkpoint_value,
131        serving_authority,
132        capabilities_registry_did,
133        max_clock_skew,
134        resolver,
135    )
136    .await?;
137
138    // §9.1 steps 2 + 4–6: hash the reconstructed leaf, check binding,
139    // fold, compare.
140    inclusion.verify_reconstructed_leaf(reconstructed_leaf)?;
141
142    Ok(inclusion)
143}