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, ¶ms, &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, ¶ms, &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, ¶ms, &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}