Skip to main content

acdp_client/
transparency.rs

1//! Opt-in transparency corroboration during verification: the
2//! RFC-ACDP-0012 §9 log-inclusion verdict and the RFC-ACDP-0015 §8
3//! witness-quorum verdict. Both are reachable from
4//! [`VerificationPolicy`](crate::VerificationPolicy), and each is reported
5//! separately from the body, receipt and revocation verdicts.
6//!
7//! RFC-ACDP-0012 §9.3: "Log-proof failure invalidates nothing else". The
8//! log and witness verdicts are therefore *reported* by default and fail
9//! verification only when the caller opts in:
10//! - [`LogInclusionPolicy::Require`] for the log;
11//! - a [`WitnessEnforcement`] other than `Report` for witnesses.
12
13use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
14
15use acdp_did::web::WebResolver;
16use acdp_primitives::error::AcdpError;
17use acdp_types::body::FullContext;
18use acdp_types::log::{LogCheckpoint, LogInclusion, LogLeaf};
19use acdp_types::receipt::RegistryReceipt;
20
21use crate::log::verify_log_checkpoint_value;
22use crate::witness::{evaluate_witness_quorum, WitnessPolicy, WitnessQuorumReport};
23
24/// RFC-ACDP-0012 §9.3 step 4 forward clock-skew allowance applied to the
25/// checkpoint carried in `log_inclusion` (RECOMMENDED 120 s, the
26/// RFC-ACDP-0011 §7 step 6 allowance).
27pub const DEFAULT_LOG_MAX_CLOCK_SKEW_SECONDS: u32 = 120;
28
29/// How verification treats a retrieval's `log_inclusion` member
30/// (RFC-ACDP-0012 §10). Default [`Self::Ignore`]: corroboration is
31/// opt-in.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LogInclusionPolicy {
34    /// Do not evaluate `log_inclusion`; preserve it verbatim. A
35    /// [`VerificationPolicy::witness_quorum`](crate::VerificationPolicy::witness_quorum)
36    /// still verifies the proof's checkpoint, because cosignatures can only
37    /// be checked against a checkpoint the consumer verified itself
38    /// (RFC-ACDP-0015 §8).
39    #[default]
40    Ignore,
41    /// Verify `log_inclusion` per RFC-ACDP-0012 §9.1 when present and
42    /// report the verdict. A failure is reported and never fails
43    /// verification (§9.3).
44    VerifyIfPresent,
45    /// Fail verification unless `log_inclusion` is present and verifies.
46    /// An absent or failing proof fails with `invalid_log_proof`.
47    /// Inclusion needs the RFC-ACDP-0010 receipt to rebuild the leaf
48    /// (§9.1 step 1), so a [`LogInclusionVerdict::CannotVerify`] fails
49    /// too, with `invalid_receipt`: the receipt is at fault, not the log
50    /// (spec #78 L1). A registry that advertises
51    /// `acdp-registry-transparency-log` MAY still leave the proof off a
52    /// response (§10). Use this only with registries known to serve it.
53    Require,
54}
55
56/// When a witness-quorum shortfall fails verification
57/// (RFC-ACDP-0015 §8.1).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59#[non_exhaustive]
60pub enum WitnessEnforcement {
61    /// Report the quorum and never fail on it.
62    #[default]
63    Report,
64    /// Fail unless `min_witnesses` distinct trusted witnesses cosigned the
65    /// checkpoint. This is the split-view and anti-rewrite use, where a
66    /// cosignature never expires.
67    RequireQuorum,
68    /// Fail unless `min_witnesses` of those cosignatures are also within
69    /// `max_age_seconds`. Use it for decisions that depend on the
70    /// checkpoint being current (§8.1).
71    RequireFreshQuorum,
72}
73
74/// Witness corroboration of the checkpoint carried in `log_inclusion`
75/// (RFC-ACDP-0015 §8).
76///
77/// Cosignatures are read from the retrieval's top-level
78/// `witness_signatures` member (§6.1). Witness trust is deployment
79/// policy, bootstrapped out of band (§13). Only the
80/// [`trusted_witnesses`](Self::trusted_witnesses) can count toward the
81/// quorum. Each witness's DID document comes from one of two places:
82/// - [`pinned_documents`](Self::pinned_documents), for a direct-from-witness
83///   deployment that already holds it (§6.2);
84/// - otherwise it is resolved: `did:web` through the verification's
85///   [`WebResolver`] (with its SSRF protections), `did:key` offline.
86///
87/// A witness whose document cannot be obtained does not count, and the
88/// reason is recorded in [`WitnessQuorumReport::failures`].
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct WitnessQuorumPolicy {
92    /// Witness DIDs the consumer trusts.
93    pub trusted_witnesses: BTreeSet<String>,
94    /// Witness DID documents held out of band, by witness DID. These take
95    /// precedence over resolution.
96    pub pinned_documents: BTreeMap<String, serde_json::Value>,
97    /// Quorum size, freshness bound and clock skew. Defaults to
98    /// [`WitnessPolicy::default`]: one witness, 300 s, 120 s.
99    pub witness: WitnessPolicy,
100    /// Whether a shortfall fails verification. Default
101    /// [`WitnessEnforcement::Report`].
102    pub enforcement: WitnessEnforcement,
103}
104
105impl WitnessQuorumPolicy {
106    /// Trust `witnesses`, with the default quorum (one witness), report-only.
107    #[must_use]
108    pub fn new<I, S>(witnesses: I) -> Self
109    where
110        I: IntoIterator<Item = S>,
111        S: Into<String>,
112    {
113        Self {
114            trusted_witnesses: witnesses.into_iter().map(Into::into).collect(),
115            pinned_documents: BTreeMap::new(),
116            witness: WitnessPolicy::default(),
117            enforcement: WitnessEnforcement::Report,
118        }
119    }
120
121    /// Use `document` for `witness_id` instead of resolving it.
122    #[must_use]
123    pub fn with_document(
124        mut self,
125        witness_id: impl Into<String>,
126        document: serde_json::Value,
127    ) -> Self {
128        self.pinned_documents.insert(witness_id.into(), document);
129        self
130    }
131
132    /// Require `n` distinct trusted witnesses.
133    #[must_use]
134    pub fn with_min_witnesses(mut self, n: u32) -> Self {
135        self.witness.min_witnesses = n;
136        self
137    }
138
139    /// Replace the quorum, freshness and skew settings.
140    #[must_use]
141    pub fn with_witness_policy(mut self, witness: WitnessPolicy) -> Self {
142        self.witness = witness;
143        self
144    }
145
146    /// Set when a shortfall fails verification.
147    #[must_use]
148    pub fn enforce(mut self, enforcement: WitnessEnforcement) -> Self {
149        self.enforcement = enforcement;
150        self
151    }
152}
153
154/// Why a served inclusion proof could not be checked (spec #78 L1,
155/// RFC-ACDP-0012 §9.1 step 1). Each names the receipt state, which is
156/// reported in full by the receipt verdict.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum CannotVerifyReason {
160    /// The response carried no `registry_receipt`.
161    ReceiptAbsent,
162    /// A `registry_receipt` was served and failed RFC-ACDP-0010 §8
163    /// verification. The failure itself is
164    /// [`VerifiedContext::receipt_verdict`](crate::VerifiedContext::receipt_verdict).
165    ReceiptInvalid,
166    /// A `registry_receipt` was served but not verified, because
167    /// [`VerificationPolicy::receipts`](crate::VerificationPolicy::receipts)
168    /// is [`ReceiptPolicy::Ignore`](crate::ReceiptPolicy::Ignore).
169    ReceiptNotVerified,
170}
171
172impl CannotVerifyReason {
173    /// A stable snake_case name for logs and reports.
174    #[must_use]
175    pub fn as_str(self) -> &'static str {
176        match self {
177            Self::ReceiptAbsent => "receipt_absent",
178            Self::ReceiptInvalid => "receipt_invalid",
179            Self::ReceiptNotVerified => "receipt_not_verified",
180        }
181    }
182}
183
184impl std::fmt::Display for CannotVerifyReason {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.write_str(self.as_str())
187    }
188}
189
190/// The RFC-ACDP-0012 §9 log-inclusion verdict, reported separately from
191/// the body, receipt and revocation verdicts (§9.3).
192///
193/// Three outcomes, never conflated:
194/// - [`Self::Included`]: the proof verified against a leaf rebuilt from
195///   verified material. Only this one means "logged".
196/// - [`Self::CannotVerify`]: the proof could not be checked, because no
197///   verified receipt was available to rebuild the leaf from (spec #78
198///   L1). It is neither logged nor `invalid_log_proof`, and it is not
199///   evidence of a log violation. An echoed `leaf` member is never used
200///   instead.
201/// - [`Self::Invalid`]: the proof was checked and failed
202///   (`invalid_log_proof`).
203#[derive(Debug, Clone)]
204#[non_exhaustive]
205pub enum LogInclusionVerdict {
206    /// The proof verified. Keep the checkpoint to check consistency with
207    /// later ones (RFC-ACDP-0012 §9.2).
208    Included(Box<LogInclusion>),
209    /// The proof could not be checked.
210    CannotVerify(CannotVerifyReason),
211    /// The proof was checked and failed ([`AcdpError::InvalidLogProof`],
212    /// or a transport error while resolving the registry key).
213    Invalid(AcdpError),
214}
215
216impl LogInclusionVerdict {
217    /// `true` only for [`Self::Included`].
218    #[must_use]
219    pub fn is_included(&self) -> bool {
220        matches!(self, Self::Included(_))
221    }
222
223    /// The verified proof, for [`Self::Included`].
224    #[must_use]
225    pub fn inclusion(&self) -> Option<&LogInclusion> {
226        match self {
227            Self::Included(inclusion) => Some(inclusion),
228            _ => None,
229        }
230    }
231
232    /// The reason, for [`Self::CannotVerify`].
233    #[must_use]
234    pub fn cannot_verify_reason(&self) -> Option<CannotVerifyReason> {
235        match self {
236            Self::CannotVerify(reason) => Some(*reason),
237            _ => None,
238        }
239    }
240
241    /// The failure, for [`Self::Invalid`].
242    #[must_use]
243    pub fn error(&self) -> Option<&AcdpError> {
244        match self {
245            Self::Invalid(e) => Some(e),
246            _ => None,
247        }
248    }
249
250    /// The report token: `included`, `cannot_verify`,
251    /// `invalid_log_proof`, or `error` for an [`Self::Invalid`] that is not
252    /// a proof failure (for example an unreachable registry DID document;
253    /// see [`AcdpError::is_transient`]).
254    #[must_use]
255    pub fn as_str(&self) -> &'static str {
256        match self {
257            Self::Included(_) => "included",
258            Self::CannotVerify(_) => "cannot_verify",
259            Self::Invalid(AcdpError::InvalidLogProof(_)) => "invalid_log_proof",
260            Self::Invalid(_) => "error",
261        }
262    }
263}
264
265/// The two transparency verdicts of one verification.
266#[derive(Debug, Clone, Default)]
267pub(crate) struct TransparencyVerdicts {
268    /// `None` when not evaluated.
269    pub(crate) log: Option<LogInclusionVerdict>,
270    /// `None` when no witness policy was set.
271    pub(crate) witness: Option<Result<WitnessQuorumReport, AcdpError>>,
272}
273
274/// The verified material the leaf is rebuilt from (§9.1 step 1).
275pub(crate) struct LeafMaterial<'a> {
276    pub(crate) receipt: &'a RegistryReceipt,
277    pub(crate) receipt_value: &'a serde_json::Value,
278}
279
280/// Evaluate the log and witness verdicts for `ctx`, then enforce the
281/// policy.
282///
283/// The caller passes a `ctx` whose `body.content_hash` is the recomputed
284/// value. `material` is the reason the leaf cannot be rebuilt when no
285/// receipt was verified.
286pub(crate) async fn evaluate(
287    client: &crate::RegistryClient,
288    ctx: &FullContext,
289    material: Result<LeafMaterial<'_>, CannotVerifyReason>,
290    serving_authority: &str,
291    log_policy: LogInclusionPolicy,
292    witness_policy: Option<&WitnessQuorumPolicy>,
293    resolver: &WebResolver,
294) -> Result<TransparencyVerdicts, AcdpError> {
295    let evaluate_log = match log_policy {
296        LogInclusionPolicy::Ignore => witness_policy.is_some(),
297        LogInclusionPolicy::VerifyIfPresent | LogInclusionPolicy::Require => true,
298    };
299    let mut out = TransparencyVerdicts::default();
300    // The §9.3-verified checkpoint, for the witness quorum (spec #78 L2).
301    let mut checkpoint: Option<Result<LogCheckpoint, AcdpError>> = None;
302
303    if evaluate_log {
304        out.log = match &ctx.log_inclusion {
305            Some(value) => {
306                let (verdict, cp) = verify_inclusion(
307                    client,
308                    ctx,
309                    value,
310                    material,
311                    serving_authority,
312                    witness_policy.is_some(),
313                    resolver,
314                )
315                .await;
316                checkpoint = cp;
317                Some(verdict)
318            }
319            None if log_policy == LogInclusionPolicy::Require => {
320                return Err(AcdpError::InvalidLogProof(
321                    "policy requires a transparency-log inclusion proof but the response \
322                     carries no log_inclusion (RFC-ACDP-0012 §10)"
323                        .into(),
324                ));
325            }
326            None => None,
327        };
328        if log_policy == LogInclusionPolicy::Require {
329            match &out.log {
330                Some(LogInclusionVerdict::Invalid(e)) => return Err(e.clone()),
331                // Fail closed on the receipt, never on the log (spec #78
332                // L1): the log has not been shown to misbehave.
333                Some(LogInclusionVerdict::CannotVerify(reason)) => {
334                    return Err(AcdpError::InvalidReceipt(format!(
335                        "policy requires a verified transparency-log inclusion proof, but \
336                         it cannot be verified without a verified registry receipt \
337                         ({reason}; RFC-ACDP-0012 §9.1 step 1)"
338                    )));
339                }
340                _ => {}
341            }
342        }
343    }
344
345    if let Some(policy) = witness_policy {
346        let verdict = match checkpoint {
347            Some(Ok(cp)) => Ok(evaluate_witnesses(ctx, &cp, policy, resolver).await),
348            Some(Err(e)) => Err(AcdpError::InvalidLogProof(format!(
349                "witness quorum not evaluated: the checkpoint did not verify ({e})"
350            ))),
351            None => Err(AcdpError::InvalidLogProof(
352                "witness quorum not evaluated: the response carries no log_inclusion \
353                 checkpoint to corroborate (RFC-ACDP-0015 §6.1)"
354                    .into(),
355            )),
356        };
357        enforce_witnesses(&verdict, policy)?;
358        out.witness = Some(verdict);
359    }
360    Ok(out)
361}
362
363/// Verify a served `log_inclusion`. Returns the log verdict and, when it
364/// was checked, the §9.3 checkpoint verdict.
365///
366/// The checkpoint is verified on its own first (§9.3 needs no leaf), so a
367/// witness quorum can be counted over it even when the inclusion is
368/// cannot-verify. Without a witness policy and without a verified receipt
369/// nothing is fetched.
370async fn verify_inclusion(
371    client: &crate::RegistryClient,
372    ctx: &FullContext,
373    value: &serde_json::Value,
374    material: Result<LeafMaterial<'_>, CannotVerifyReason>,
375    serving_authority: &str,
376    need_checkpoint: bool,
377    resolver: &WebResolver,
378) -> (
379    LogInclusionVerdict,
380    Option<Result<LogCheckpoint, AcdpError>>,
381) {
382    if let Err(reason) = &material {
383        if !need_checkpoint {
384            return (LogInclusionVerdict::CannotVerify(*reason), None);
385        }
386    }
387    let checkpoint = verify_checkpoint(client, value, serving_authority, resolver).await;
388    let material = match material {
389        Ok(material) => material,
390        Err(reason) => return (LogInclusionVerdict::CannotVerify(reason), Some(checkpoint)),
391    };
392    let verdict = match &checkpoint {
393        Err(e) => LogInclusionVerdict::Invalid(e.clone()),
394        Ok(_) => match check_leaf(ctx, value, &material) {
395            Ok(inclusion) => LogInclusionVerdict::Included(Box::new(inclusion)),
396            Err(e) => LogInclusionVerdict::Invalid(e),
397        },
398    };
399    (verdict, Some(checkpoint))
400}
401
402/// §9.3 over the proof's embedded checkpoint, bound to
403/// `capabilities.registry_did` fetched from the serving authority.
404async fn verify_checkpoint(
405    client: &crate::RegistryClient,
406    value: &serde_json::Value,
407    serving_authority: &str,
408    resolver: &WebResolver,
409) -> Result<LogCheckpoint, AcdpError> {
410    // Closed parse of the whole proof first, as §9.1 does.
411    LogInclusion::from_value(value)?;
412    let checkpoint_value = value.get("log_checkpoint").ok_or_else(|| {
413        AcdpError::InvalidLogProof("log_inclusion has no log_checkpoint member".into())
414    })?;
415    let caps = client.capabilities().await?;
416    verify_log_checkpoint_value(
417        checkpoint_value,
418        serving_authority,
419        &caps.registry_did,
420        chrono::Duration::seconds(i64::from(DEFAULT_LOG_MAX_CLOCK_SKEW_SECONDS)),
421        resolver,
422    )
423    .await
424}
425
426/// §9.1 steps 1–2 and 4–6 against a checkpoint already verified.
427fn check_leaf(
428    ctx: &FullContext,
429    value: &serde_json::Value,
430    material: &LeafMaterial<'_>,
431) -> Result<LogInclusion, AcdpError> {
432    // The receipt's key_fingerprint was cross-checked against the
433    // resolved producer key by RFC-ACDP-0010 §8, so it is verified
434    // material. Any echoed `leaf` member is ignored.
435    let leaf = LogLeaf::for_publish(
436        &ctx.body,
437        &ctx.body.content_hash,
438        &material.receipt.key_fingerprint,
439        material.receipt_value,
440    )?;
441    let inclusion = LogInclusion::from_value(value)?;
442    inclusion.verify_reconstructed_leaf(&leaf)?;
443    Ok(inclusion)
444}
445
446async fn evaluate_witnesses(
447    ctx: &FullContext,
448    checkpoint: &LogCheckpoint,
449    policy: &WitnessQuorumPolicy,
450    resolver: &WebResolver,
451) -> WitnessQuorumReport {
452    let cosignatures: Vec<serde_json::Value> = match ctx.extensions.get("witness_signatures") {
453        Some(serde_json::Value::Array(items)) => items.clone(),
454        _ => Vec::new(),
455    };
456    // Only witnesses that are trusted AND named by a served cosignature
457    // need a document; resolving the rest would be wasted fetches.
458    let named: HashSet<&str> = cosignatures
459        .iter()
460        .filter_map(|c| c.get("witness_id").and_then(|v| v.as_str()))
461        .collect();
462    let mut docs: HashMap<String, serde_json::Value> = HashMap::new();
463    let mut resolution_failures = Vec::new();
464    for witness in policy
465        .trusted_witnesses
466        .iter()
467        .filter(|w| named.contains(w.as_str()))
468    {
469        match witness_document(witness, policy, resolver).await {
470            Ok(doc) => {
471                docs.insert(witness.clone(), doc);
472            }
473            Err(e) => resolution_failures.push(AcdpError::InvalidWitnessCosignature(format!(
474                "could not obtain the DID document of trusted witness '{witness}': {e}"
475            ))),
476        }
477    }
478    let trusted: HashSet<String> = policy.trusted_witnesses.iter().cloned().collect();
479    let mut report = evaluate_witness_quorum(
480        &cosignatures,
481        &docs,
482        &trusted,
483        checkpoint,
484        &policy.witness,
485        None,
486    );
487    // `evaluate_witness_quorum` also records the missing document; keep the
488    // more specific resolution error in front of it.
489    resolution_failures.append(&mut report.failures);
490    report.failures = resolution_failures;
491    report
492}
493
494async fn witness_document(
495    witness: &str,
496    policy: &WitnessQuorumPolicy,
497    resolver: &WebResolver,
498) -> Result<serde_json::Value, AcdpError> {
499    if let Some(doc) = policy.pinned_documents.get(witness) {
500        return Ok(doc.clone());
501    }
502    if witness.starts_with("did:key:") {
503        return Ok(serde_json::to_value(acdp_did::key::did_key_document(
504            witness,
505        )?)?);
506    }
507    Ok(serde_json::to_value(resolver.resolve(witness).await?)?)
508}
509
510fn enforce_witnesses(
511    verdict: &Result<WitnessQuorumReport, AcdpError>,
512    policy: &WitnessQuorumPolicy,
513) -> Result<(), AcdpError> {
514    let (needed, fresh) = match policy.enforcement {
515        WitnessEnforcement::Report => return Ok(()),
516        WitnessEnforcement::RequireQuorum => (policy.witness.min_witnesses, false),
517        WitnessEnforcement::RequireFreshQuorum => (policy.witness.min_witnesses, true),
518    };
519    let report = verdict.as_ref().map_err(Clone::clone)?;
520    let (met, count) = if fresh {
521        (report.meets_fresh_quorum, report.fresh_witnessed_count)
522    } else {
523        (report.meets_quorum, report.witnessed_count)
524    };
525    if met {
526        return Ok(());
527    }
528    Err(AcdpError::InvalidWitnessCosignature(format!(
529        "witness quorum not met: {count} of {needed} required distinct trusted witnesses{} \
530         cosigned the checkpoint (RFC-ACDP-0015 §8.1){}",
531        if fresh {
532            " (within max_age_seconds)"
533        } else {
534            ""
535        },
536        match report.failures.first() {
537            Some(e) => format!("; first failure: {e}"),
538            None => String::new(),
539        }
540    )))
541}