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}