Skip to main content

didwebvh_rs/
validate.rs

1/*!
2*   Highest level validation logic for a webvh entry
3*
4*   Step 1: Load LogEntries and validate each LogEntry
5*   Step 2: Get the highest LogEntry versionId
6*   Step 3: Load the Witness proofs and generate Witness State
7*   Step 4: Validate LogEntry Witness Proofs against each other
8*   Step 5: Fully validated WebVH DID result
9*/
10
11use chrono::{Duration, Utc};
12use std::sync::Arc;
13use tracing::{debug, error};
14
15use crate::{
16    DIDWebVHError, DIDWebVHState,
17    log_entry_state::{LogEntryState, LogEntryValidationStatus},
18    witness::WitnessVerifyOptions,
19};
20
21/// Why log-entry validation stopped before consuming every loaded entry.
22///
23/// Returned inside [`ValidationReport::truncated`] when entries loaded into
24/// [`DIDWebVHState`] were dropped during validation. Two cases:
25///
26/// - [`Self::VerificationFailed`]: a later entry failed signature or
27///   parameter verification. The chain up to the failing entry's predecessor
28///   is still usable; the failing entry *and everything after it* has been
29///   dropped from [`DIDWebVHState::log_entries`]. The underlying
30///   [`DIDWebVHError`] is carried structurally in an [`Arc`] so callers can
31///   pattern-match on the specific error variant without this enum having
32///   to be `Clone`-incompatible.
33/// - [`Self::PostDeactivation`]: a valid deactivation entry was followed by
34///   additional entries in the loaded log. Per spec a deactivated DID
35///   cannot be updated, so those trailing entries are dropped. Surfacing
36///   this loudly matters: silently accepting a `[genesis, deactivate,
37///   attacker-appended]` log would let an attacker hide tampered entries
38///   behind a real deactivation.
39///
40/// `#[non_exhaustive]` lets future variants land without breaking
41/// downstream matches.
42#[derive(Debug, Clone)]
43#[non_exhaustive]
44pub enum TruncationReason {
45    /// A log entry failed verification (bad proof, parameter transition,
46    /// hash chain, etc.). Contains the failing entry's `versionId` and the
47    /// structural error.
48    VerificationFailed {
49        /// `versionId` of the entry at which verification stopped.
50        at_version_id: String,
51        /// The underlying error. Wrapped in [`Arc`] because
52        /// [`DIDWebVHError`] is not `Clone` (some variants wrap non-cloneable
53        /// `reqwest` / `serde_json` sources). Callers that want the
54        /// variant should pattern-match on `&*error`; callers that just
55        /// want a string can `error.to_string()`.
56        error: Arc<DIDWebVHError>,
57    },
58    /// Entries past a valid deactivation entry were dropped. The controller
59    /// cannot legitimately extend a deactivated log; a resolver that sees
60    /// such entries should treat them as tampering attempts rather than
61    /// silently truncating.
62    PostDeactivation {
63        /// `versionId` of the deactivation entry.
64        deactivated_at: String,
65        /// Number of entries in the loaded log past the deactivation entry
66        /// that were dropped.
67        dropped_entries: u32,
68    },
69}
70
71impl TruncationReason {
72    /// The `versionId` at which the truncation happened — the failing entry
73    /// for `VerificationFailed`, the deactivation entry for
74    /// `PostDeactivation`.
75    pub fn at_version_id(&self) -> &str {
76        match self {
77            Self::VerificationFailed { at_version_id, .. }
78            | Self::PostDeactivation {
79                deactivated_at: at_version_id,
80                ..
81            } => at_version_id,
82        }
83    }
84}
85
86/// Summary of a call to [`DIDWebVHState::validate`].
87///
88/// Always carries the `versionId` of the last-known-good entry in `ok_until`.
89/// If `truncated` is `Some`, entries past that point were dropped — see
90/// [`TruncationReason`] for the two cases.
91///
92/// # Handling the report
93///
94/// ```ignore
95/// // Strict: fail on any truncation (recommended for resolvers).
96/// state.validate()?.assert_complete()?;
97///
98/// // Best-effort: accept partial logs, inspect truncation manually.
99/// let report = state.validate()?;
100/// if let Some(reason) = &report.truncated {
101///     tracing::warn!(?reason, "partial validation");
102/// }
103/// ```
104///
105/// # On `#[must_use]`
106///
107/// The `#[must_use]` attribute catches the most obvious misuse —
108/// `state.validate();` as a bare statement — and forces a call-site
109/// binding. **It does not catch propagation-and-drop**: `state.validate()?;`
110/// still compiles cleanly, because `?` consumes the `Result` and drops
111/// the `Ok(ValidationReport)` without triggering the lint. Reach for
112/// [`Self::assert_complete`] whenever you need the stricter "succeed only
113/// if every loaded entry validated" contract — that is the one call that
114/// turns truncation into an `Err`.
115#[must_use = "a ValidationReport may contain a truncation that the caller must handle — \
116              call .assert_complete() to turn it into an error, or inspect .truncated"]
117#[derive(Debug, Clone)]
118pub struct ValidationReport {
119    /// `versionId` of the last log entry that validated successfully.
120    pub ok_until: String,
121    /// `Some` if some entries in the loaded log did not survive validation.
122    /// See [`TruncationReason`] for the variants (verification failure vs
123    /// post-deactivation tampering).
124    pub truncated: Option<TruncationReason>,
125}
126
127impl ValidationReport {
128    /// Returns `Err` if the report indicates any truncation.
129    ///
130    /// Convenience for the common "strict resolver" case — a caller that
131    /// wants `Ok(())` only when every loaded entry validated can write
132    /// `state.validate()?.assert_complete()?`.
133    pub fn assert_complete(self) -> Result<(), DIDWebVHError> {
134        let Some(reason) = &self.truncated else {
135            return Ok(());
136        };
137        let version = crate::log_entry::parse_version_id_fields(reason.at_version_id())
138            .map(|(n, _)| n)
139            .unwrap_or(0);
140        let msg = match reason {
141            TruncationReason::VerificationFailed {
142                at_version_id,
143                error,
144            } => format!(
145                "Log truncated at {at_version_id}: {error}. Last valid entry: {}.",
146                self.ok_until,
147            ),
148            TruncationReason::PostDeactivation {
149                deactivated_at,
150                dropped_entries,
151            } => format!(
152                "Log contains {dropped_entries} entries past the deactivation entry \
153                 at {deactivated_at}; a deactivated DID cannot be updated."
154            ),
155        };
156        Err(DIDWebVHError::validation(msg, version))
157    }
158}
159
160impl DIDWebVHState {
161    /// Validates all LogEntries and their witness proofs.
162    ///
163    /// Walks the log entry chain in order, verifying each entry's signature and
164    /// parameter transitions. If a later entry fails, entries from that point on
165    /// are dropped from `self.log_entries` and the failure is reported via
166    /// [`ValidationReport::truncated`] — callers that want to reject partial
167    /// chains should call [`ValidationReport::assert_complete`]. After log
168    /// entry validation, witness proofs are verified against the configured
169    /// threshold for each surviving entry.
170    ///
171    /// Sets `self.validated = true` and computes `self.expires` on success.
172    /// Returns an error only if the *first* entry is invalid (no fallback
173    /// possible) or if witness-proof validation fails.
174    pub fn validate(&mut self) -> Result<ValidationReport, DIDWebVHError> {
175        self.validate_with(&WitnessVerifyOptions::new())
176    }
177
178    /// Variant of [`Self::validate`] that accepts runtime [`WitnessVerifyOptions`]
179    /// — useful for consumers that need to widen the accepted witness cryptosuites
180    /// (e.g. post-quantum interop testing) without recompiling.
181    pub fn validate_with(
182        &mut self,
183        options: &WitnessVerifyOptions,
184    ) -> Result<ValidationReport, DIDWebVHError> {
185        // Validate each LogEntry
186        let original_len = self.log_entries.len();
187        let mut previous_entry: Option<&LogEntryState> = None;
188        let mut truncated: Option<TruncationReason> = None;
189        // Records where deactivation happened so post-deactivation drops
190        // can be surfaced in the report.
191        let mut deactivation_info: Option<(usize, String)> = None;
192
193        for (idx, entry) in self.log_entries.iter_mut().enumerate() {
194            match entry.verify_log_entry(previous_entry) {
195                Ok(()) => (),
196                Err(e) => {
197                    error!(
198                        "There was an issue with LogEntry: {}! Reason: {e}",
199                        entry.get_version_id()
200                    );
201                    if previous_entry.is_some() {
202                        // Record truncation and fall back to last known good.
203                        truncated = Some(TruncationReason::VerificationFailed {
204                            at_version_id: entry.get_version_id().to_string(),
205                            error: Arc::new(e),
206                        });
207                        break;
208                    }
209                    return Err(DIDWebVHError::validation(
210                        format!("No valid LogEntry found! Reason: {e}"),
211                        entry.version_number,
212                    ));
213                }
214            }
215            // Check if this valid LogEntry has been deactivated, if so then ignore any other
216            // Entries
217            if let Some(deactivated) = entry.validated_parameters.deactivated
218                && deactivated
219            {
220                // Deactivated, return the current LogEntry and MetaData
221                self.deactivated = true;
222                deactivation_info = Some((idx, entry.get_version_id().to_string()));
223            }
224
225            // Set the next previous records
226            previous_entry = Some(entry);
227
228            if self.deactivated {
229                // If we have a deactivated entry, we stop processing further entries
230                break;
231            }
232        }
233
234        // Post-deactivation entries: entries loaded past the deactivation
235        // entry cannot legitimately exist (a deactivated DID is terminal).
236        // Only report this when we didn't already record a verification
237        // failure — the two are mutually exclusive, but guard anyway.
238        if truncated.is_none()
239            && let Some((idx, deactivated_at)) = deactivation_info
240        {
241            let dropped = original_len.saturating_sub(idx + 1);
242            if dropped > 0 {
243                error!(
244                    "Log contains {dropped} entries past deactivation at {deactivated_at}; \
245                     treating as tampering."
246                );
247                truncated = Some(TruncationReason::PostDeactivation {
248                    deactivated_at,
249                    dropped_entries: u32::try_from(dropped).unwrap_or(u32::MAX),
250                });
251            }
252        }
253
254        // Cleanup any LogEntries that are after deactivated or invalid after last ok LogEntry
255        self.log_entries
256            .retain(|entry| entry.validation_status == LogEntryValidationStatus::LogEntryOnly);
257        if self.log_entries.is_empty() {
258            return Err(DIDWebVHError::ValidationError(
259                "No validated LogEntries exist".to_string(),
260            ));
261        }
262
263        // Step 1: COMPLETED. LogEntries are verified and only contains good Entries
264
265        // Step 2: Get the highest validated version number
266        let highest_version_number = self
267            .log_entries
268            .last()
269            .expect("guarded by empty check above")
270            .get_version_number();
271        debug!("Latest LogEntry ID = ({})", highest_version_number);
272
273        // Step 3: Recalculate witness proofs based on the highest LogEntry version
274        self.witness_proofs
275            .generate_proof_state(highest_version_number)?;
276
277        // Step 4: Validate the witness proofs
278        for log_entry in self.log_entries.iter_mut() {
279            debug!("Witness Proof Validating: {}", log_entry.get_version_id());
280            self.witness_proofs
281                .validate_log_entry(log_entry, highest_version_number, options)?;
282            log_entry.validation_status = LogEntryValidationStatus::Ok;
283        }
284
285        // Set to validated and timestamp
286        self.validated = true;
287        let last_log_entry = self
288            .log_entries
289            .last()
290            .expect("guarded by empty check above");
291        self.scid = if let Some(scid) = &last_log_entry.validated_parameters.scid {
292            scid.to_string()
293        } else {
294            return Err(DIDWebVHError::ValidationError(
295                "No SCID found in last LogEntry".to_string(),
296            ));
297        };
298        let ttl = if let Some(ttl) = last_log_entry.validated_parameters.ttl {
299            if ttl == 0 { 3600_u32 } else { ttl }
300        } else {
301            // Use default TTL of 1 hour
302            3600_u32
303        };
304
305        self.expires = Utc::now().fixed_offset() + Duration::seconds(i64::from(ttl));
306
307        let ok_until = last_log_entry.get_version_id().to_string();
308        Ok(ValidationReport {
309            ok_until,
310            truncated,
311        })
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use crate::{
318        DIDWebVHState, Multibase,
319        log_entry_state::{LogEntryState, LogEntryValidationStatus},
320        parameters::Parameters,
321        test_utils::{did_doc_with_key, generate_signing_key},
322    };
323    use chrono::{Duration, Utc};
324    use serde_json::json;
325    use std::sync::Arc;
326
327    /// Creates a valid, signed `DIDWebVHState` containing exactly one log entry.
328    ///
329    /// An optional `ttl` parameter allows TTL-specific tests to reuse this helper
330    /// instead of duplicating the setup. After creation, the validation status is
331    /// reset to `NotValidated` so that `validate()` can be exercised from scratch.
332    async fn create_single_entry_state(ttl: Option<u32>) -> DIDWebVHState {
333        let base_time = (Utc::now() - Duration::seconds(10)).fixed_offset();
334        let key = generate_signing_key();
335        let params = Parameters {
336            update_keys: Some(Arc::new(vec![Multibase::new(
337                key.get_public_keymultibase().unwrap(),
338            )])),
339            portable: Some(false),
340            ttl,
341            ..Default::default()
342        };
343        let doc = did_doc_with_key("did:webvh:{SCID}:localhost%3A8000", &key);
344
345        let mut state = DIDWebVHState::default();
346        state
347            .create_log_entry(Some(base_time), &doc, &params, &key)
348            .await
349            .expect("Failed to create first entry");
350        // Reset validation status to NotValidated so validate() can run
351        for entry in &mut state.log_entries {
352            entry.validation_status = LogEntryValidationStatus::NotValidated;
353        }
354        state
355    }
356
357    /// Tests that a single valid, signed log entry passes full validation.
358    ///
359    /// After validation the state should be marked as validated and the SCID
360    /// (Self-Certifying Identifier) should be populated. This is the baseline
361    /// happy-path test -- if a single well-formed entry cannot be validated,
362    /// no WebVH DID resolution can succeed.
363    #[tokio::test]
364    async fn test_validate_single_valid_entry() {
365        let mut state = create_single_entry_state(None).await;
366        let report = state.validate().expect("Validation should pass");
367        assert!(report.truncated.is_none());
368        assert!(state.validated);
369        assert!(!state.scid.is_empty());
370    }
371
372    /// Tests that a deactivated DID stops log entry processing at the deactivation point.
373    ///
374    /// When a log entry sets `deactivated: true`, the validator must stop processing
375    /// any subsequent entries and mark the overall state as deactivated. Both the
376    /// initial entry and the deactivation entry should be retained (2 entries total).
377    /// This matters because a deactivated DID must not accept further updates, and
378    /// resolvers need to know the DID is no longer active.
379    #[tokio::test]
380    async fn test_validate_deactivated_stops_processing() {
381        let base_time = (Utc::now() - Duration::seconds(100)).fixed_offset();
382        let key = generate_signing_key();
383        let params = Parameters {
384            update_keys: Some(Arc::new(vec![Multibase::new(
385                key.get_public_keymultibase().unwrap(),
386            )])),
387            portable: Some(false),
388            ..Default::default()
389        };
390        let doc = did_doc_with_key("did:webvh:{SCID}:localhost%3A8000", &key);
391
392        let mut state = DIDWebVHState::default();
393        state
394            .create_log_entry(Some(base_time), &doc, &params, &key)
395            .await
396            .unwrap();
397
398        let actual_doc = state.log_entries.last().unwrap().get_state().clone();
399
400        // Create deactivation entry
401        let deact_params = Parameters {
402            update_keys: Some(Arc::new(vec![])),
403            deactivated: Some(true),
404            ..Default::default()
405        };
406        state
407            .create_log_entry(
408                Some(base_time + Duration::seconds(1)),
409                &actual_doc,
410                &deact_params,
411                &key,
412            )
413            .await
414            .unwrap();
415
416        // Reset validation status
417        for entry in &mut state.log_entries {
418            entry.validation_status = LogEntryValidationStatus::NotValidated;
419        }
420
421        let report = state.validate().unwrap();
422        assert!(report.truncated.is_none());
423        assert!(state.deactivated);
424        // Should still have 2 entries (both valid, but deactivated stops further processing)
425        assert_eq!(state.log_entries.len(), 2);
426    }
427
428    /// Tests that entries loaded past a valid deactivation are surfaced as
429    /// [`TruncationReason::PostDeactivation`] and then dropped.
430    ///
431    /// Models a tampering scenario: `[genesis, deactivate, attacker-appended]`.
432    /// Before this was surfaced, `validate()` returned `Ok` with no truncation
433    /// reported — the attacker-appended entry was silently dropped by `retain`.
434    /// A resolver had no way to tell apart a clean deactivated log from one
435    /// with extra junk after the deactivation. Now the report tells the caller
436    /// exactly how many entries past the deactivation were dropped, and
437    /// `assert_complete()` turns it into a hard error.
438    #[tokio::test]
439    async fn test_validate_entries_past_deactivation_reported() {
440        use super::TruncationReason;
441
442        let base_time = (Utc::now() - Duration::seconds(1000)).fixed_offset();
443        let key = generate_signing_key();
444        let params = Parameters {
445            update_keys: Some(Arc::new(vec![Multibase::new(
446                key.get_public_keymultibase().unwrap(),
447            )])),
448            portable: Some(false),
449            ..Default::default()
450        };
451        let doc = did_doc_with_key("did:webvh:{SCID}:localhost%3A8000", &key);
452
453        let mut state = DIDWebVHState::default();
454        state
455            .create_log_entry(Some(base_time), &doc, &params, &key)
456            .await
457            .unwrap();
458        let actual_doc = state.log_entries.last().unwrap().get_state().clone();
459
460        // Legit deactivation entry.
461        state
462            .create_log_entry(
463                Some(base_time + Duration::seconds(1)),
464                &actual_doc,
465                &Parameters {
466                    update_keys: Some(Arc::new(vec![])),
467                    deactivated: Some(true),
468                    ..Default::default()
469                },
470                &key,
471            )
472            .await
473            .unwrap();
474
475        // Attacker-appended entry: push a stub directly into the vec. We
476        // don't need to sign it — validate() breaks on the deactivation
477        // entry before ever reaching this one, so the trailing entry is
478        // dropped as PostDeactivation, not as VerificationFailed. Models
479        // what a resolver sees when loading a tampered did.jsonl from disk
480        // or HTTP: all entries load, then validation walks them in order.
481        state.log_entries.push(LogEntryState {
482            log_entry: crate::log_entry::LogEntry::Spec1_0(
483                crate::log_entry::spec_1_0::LogEntry1_0 {
484                    version_id: "3-ZZZZattackerappended".to_string(),
485                    version_time: (base_time + Duration::seconds(2)).fixed_offset(),
486                    parameters: crate::parameters::spec_1_0::Parameters1_0::default(),
487                    state: actual_doc.clone(),
488                    proof: vec![],
489                },
490            ),
491            version_number: 3,
492            validated_parameters: Parameters::default(),
493            validation_status: LogEntryValidationStatus::NotValidated,
494        });
495        // `create_log_entry` flipped state.deactivated to true during the
496        // deactivation entry — clear it so validate() gets the unvalidated
497        // state a freshly-loaded log would present.
498        state.deactivated = false;
499
500        // Reset validation status so validate() walks fresh.
501        for entry in &mut state.log_entries {
502            entry.validation_status = LogEntryValidationStatus::NotValidated;
503        }
504        assert_eq!(state.log_entries.len(), 3);
505
506        let report = state.validate().unwrap();
507        // The deactivation entry and everything before it survives.
508        assert_eq!(state.log_entries.len(), 2);
509        assert!(state.deactivated);
510        // The trailing entry is flagged, not silently dropped.
511        let Some(TruncationReason::PostDeactivation {
512            ref deactivated_at,
513            dropped_entries,
514        }) = report.truncated
515        else {
516            panic!("expected PostDeactivation, got {:?}", report.truncated);
517        };
518        assert_eq!(dropped_entries, 1);
519        assert!(deactivated_at.starts_with("2-"));
520
521        // assert_complete() must refuse the report.
522        let err = report.assert_complete().unwrap_err();
523        assert!(err.to_string().contains("past the deactivation entry"));
524    }
525
526    /// Tests that an invalid first log entry produces an immediate error.
527    ///
528    /// If the very first entry in the log is malformed (e.g., missing a proof),
529    /// there is no previous valid entry to fall back to. The validator must return
530    /// a "No valid LogEntry found" error rather than silently succeeding with an
531    /// empty state. This guards the invariant that every WebVH DID must begin with
532    /// a cryptographically valid genesis entry.
533    #[test]
534    fn test_validate_invalid_first_entry_error() {
535        let mut state = DIDWebVHState::default();
536        // Push an invalid entry with no proof
537        state.log_entries.push(LogEntryState {
538            log_entry: crate::log_entry::LogEntry::Spec1_0(
539                crate::log_entry::spec_1_0::LogEntry1_0 {
540                    version_id: "1-abc".to_string(),
541                    version_time: Utc::now().fixed_offset(),
542                    parameters: crate::parameters::spec_1_0::Parameters1_0::default(),
543                    state: json!({}),
544                    proof: vec![],
545                },
546            ),
547            version_number: 1,
548            validated_parameters: Parameters::default(),
549            validation_status: LogEntryValidationStatus::NotValidated,
550        });
551
552        let err = state.validate().unwrap_err();
553        assert!(err.to_string().contains("No valid LogEntry found"));
554    }
555
556    /// Validates TTL behavior by creating a state with the given TTL, validating it,
557    /// and asserting the expiration is within the expected range.
558    async fn assert_ttl_produces_expiry(ttl: Option<u32>, expected_seconds: i64) {
559        let mut state = create_single_entry_state(ttl).await;
560        let _report = state.validate().unwrap();
561        let now = Utc::now().fixed_offset();
562        let diff = state.expires - now;
563        assert!(
564            diff.num_seconds() > (expected_seconds - 100) && diff.num_seconds() <= expected_seconds,
565            "Expected expiry ~{expected_seconds}s, got {}s",
566            diff.num_seconds()
567        );
568    }
569
570    /// Tests that validation applies the default TTL of 3600 seconds (1 hour) when no
571    /// TTL is specified in the parameters.
572    ///
573    /// A sensible default TTL is important so that resolvers know how long they can
574    /// cache a resolved DID document before re-fetching.
575    #[tokio::test]
576    async fn test_validate_ttl_default() {
577        assert_ttl_produces_expiry(None, 3600).await;
578    }
579
580    /// Tests that a TTL value of zero is treated as the default TTL of 3600 seconds.
581    ///
582    /// A zero TTL would cause immediate expiration, which is not useful. The validator
583    /// treats TTL=0 as "use default" to prevent accidental misconfiguration from making
584    /// a DID effectively unresolvable due to instant cache expiry.
585    #[tokio::test]
586    async fn test_validate_ttl_zero_defaults_to_3600() {
587        assert_ttl_produces_expiry(Some(0), 3600).await;
588    }
589
590    /// Tests that a custom TTL value (7200 seconds / 2 hours) is honored by the validator.
591    ///
592    /// When the parameters specify a non-zero TTL, the validated state's expiration
593    /// should reflect that exact duration. This ensures DID publishers can control how
594    /// long resolvers cache their DID documents.
595    #[tokio::test]
596    async fn test_validate_ttl_custom() {
597        assert_ttl_produces_expiry(Some(7200), 7200).await;
598    }
599
600    /// Tests that validating a state with no log entries at all returns an error.
601    ///
602    /// An empty log is not a valid WebVH DID -- there must be at least a genesis entry.
603    /// The validator must return a "No validated LogEntries" error to prevent resolvers
604    /// from accepting a DID that has no verifiable history.
605    #[test]
606    fn test_validate_no_log_entries_error() {
607        let mut state = DIDWebVHState::default();
608        let err = state.validate().unwrap_err();
609        assert!(err.to_string().contains("No validated LogEntries"));
610    }
611}