use chrono::{Duration, Utc};
use std::sync::Arc;
use tracing::{debug, error};
use crate::{
DIDWebVHError, DIDWebVHState,
log_entry_state::{LogEntryState, LogEntryValidationStatus},
witness::WitnessVerifyOptions,
};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum TruncationReason {
VerificationFailed {
at_version_id: String,
error: Arc<DIDWebVHError>,
},
PostDeactivation {
deactivated_at: String,
dropped_entries: u32,
},
}
impl TruncationReason {
pub fn at_version_id(&self) -> &str {
match self {
Self::VerificationFailed { at_version_id, .. }
| Self::PostDeactivation {
deactivated_at: at_version_id,
..
} => at_version_id,
}
}
}
#[must_use = "a ValidationReport may contain a truncation that the caller must handle — \
call .assert_complete() to turn it into an error, or inspect .truncated"]
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub ok_until: String,
pub truncated: Option<TruncationReason>,
}
impl ValidationReport {
pub fn assert_complete(self) -> Result<(), DIDWebVHError> {
let Some(reason) = &self.truncated else {
return Ok(());
};
let version = crate::log_entry::parse_version_id_fields(reason.at_version_id())
.map(|(n, _)| n)
.unwrap_or(0);
let msg = match reason {
TruncationReason::VerificationFailed {
at_version_id,
error,
} => format!(
"Log truncated at {at_version_id}: {error}. Last valid entry: {}.",
self.ok_until,
),
TruncationReason::PostDeactivation {
deactivated_at,
dropped_entries,
} => format!(
"Log contains {dropped_entries} entries past the deactivation entry \
at {deactivated_at}; a deactivated DID cannot be updated."
),
};
Err(DIDWebVHError::validation(msg, version))
}
}
impl DIDWebVHState {
pub fn validate(&mut self) -> Result<ValidationReport, DIDWebVHError> {
self.validate_with(&WitnessVerifyOptions::new())
}
pub fn validate_with(
&mut self,
options: &WitnessVerifyOptions,
) -> Result<ValidationReport, DIDWebVHError> {
let original_len = self.log_entries.len();
let mut previous_entry: Option<&LogEntryState> = None;
let mut truncated: Option<TruncationReason> = None;
let mut deactivation_info: Option<(usize, String)> = None;
for (idx, entry) in self.log_entries.iter_mut().enumerate() {
match entry.verify_log_entry(previous_entry) {
Ok(()) => (),
Err(e) => {
error!(
"There was an issue with LogEntry: {}! Reason: {e}",
entry.get_version_id()
);
if previous_entry.is_some() {
truncated = Some(TruncationReason::VerificationFailed {
at_version_id: entry.get_version_id().to_string(),
error: Arc::new(e),
});
break;
}
return Err(DIDWebVHError::validation(
format!("No valid LogEntry found! Reason: {e}"),
entry.version_number,
));
}
}
if let Some(deactivated) = entry.validated_parameters.deactivated
&& deactivated
{
self.deactivated = true;
deactivation_info = Some((idx, entry.get_version_id().to_string()));
}
previous_entry = Some(entry);
if self.deactivated {
break;
}
}
if truncated.is_none()
&& let Some((idx, deactivated_at)) = deactivation_info
{
let dropped = original_len.saturating_sub(idx + 1);
if dropped > 0 {
error!(
"Log contains {dropped} entries past deactivation at {deactivated_at}; \
treating as tampering."
);
truncated = Some(TruncationReason::PostDeactivation {
deactivated_at,
dropped_entries: u32::try_from(dropped).unwrap_or(u32::MAX),
});
}
}
self.log_entries
.retain(|entry| entry.validation_status == LogEntryValidationStatus::LogEntryOnly);
if self.log_entries.is_empty() {
return Err(DIDWebVHError::ValidationError(
"No validated LogEntries exist".to_string(),
));
}
let highest_version_number = self
.log_entries
.last()
.expect("guarded by empty check above")
.get_version_number();
debug!("Latest LogEntry ID = ({})", highest_version_number);
self.witness_proofs
.generate_proof_state(highest_version_number)?;
for log_entry in self.log_entries.iter_mut() {
debug!("Witness Proof Validating: {}", log_entry.get_version_id());
self.witness_proofs
.validate_log_entry(log_entry, highest_version_number, options)?;
log_entry.validation_status = LogEntryValidationStatus::Ok;
}
self.validated = true;
let last_log_entry = self
.log_entries
.last()
.expect("guarded by empty check above");
self.scid = if let Some(scid) = &last_log_entry.validated_parameters.scid {
scid.to_string()
} else {
return Err(DIDWebVHError::ValidationError(
"No SCID found in last LogEntry".to_string(),
));
};
let ttl = if let Some(ttl) = last_log_entry.validated_parameters.ttl {
if ttl == 0 { 3600_u32 } else { ttl }
} else {
3600_u32
};
self.expires = Utc::now().fixed_offset() + Duration::seconds(i64::from(ttl));
let ok_until = last_log_entry.get_version_id().to_string();
Ok(ValidationReport {
ok_until,
truncated,
})
}
}
#[cfg(test)]
mod tests {
use crate::{
DIDWebVHState, Multibase,
log_entry_state::{LogEntryState, LogEntryValidationStatus},
parameters::Parameters,
test_utils::{did_doc_with_key, generate_signing_key},
};
use chrono::{Duration, Utc};
use serde_json::json;
use std::sync::Arc;
async fn create_single_entry_state(ttl: Option<u32>) -> DIDWebVHState {
let base_time = (Utc::now() - Duration::seconds(10)).fixed_offset();
let key = generate_signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
ttl,
..Default::default()
};
let doc = did_doc_with_key("did:webvh:{SCID}:localhost%3A8000", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(base_time), &doc, ¶ms, &key)
.await
.expect("Failed to create first entry");
for entry in &mut state.log_entries {
entry.validation_status = LogEntryValidationStatus::NotValidated;
}
state
}
#[tokio::test]
async fn test_validate_single_valid_entry() {
let mut state = create_single_entry_state(None).await;
let report = state.validate().expect("Validation should pass");
assert!(report.truncated.is_none());
assert!(state.validated);
assert!(!state.scid.is_empty());
}
#[tokio::test]
async fn test_validate_deactivated_stops_processing() {
let base_time = (Utc::now() - Duration::seconds(100)).fixed_offset();
let key = generate_signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
..Default::default()
};
let doc = did_doc_with_key("did:webvh:{SCID}:localhost%3A8000", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(base_time), &doc, ¶ms, &key)
.await
.unwrap();
let actual_doc = state.log_entries.last().unwrap().get_state().clone();
let deact_params = Parameters {
update_keys: Some(Arc::new(vec![])),
deactivated: Some(true),
..Default::default()
};
state
.create_log_entry(
Some(base_time + Duration::seconds(1)),
&actual_doc,
&deact_params,
&key,
)
.await
.unwrap();
for entry in &mut state.log_entries {
entry.validation_status = LogEntryValidationStatus::NotValidated;
}
let report = state.validate().unwrap();
assert!(report.truncated.is_none());
assert!(state.deactivated);
assert_eq!(state.log_entries.len(), 2);
}
#[tokio::test]
async fn test_validate_entries_past_deactivation_reported() {
use super::TruncationReason;
let base_time = (Utc::now() - Duration::seconds(1000)).fixed_offset();
let key = generate_signing_key();
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(
key.get_public_keymultibase().unwrap(),
)])),
portable: Some(false),
..Default::default()
};
let doc = did_doc_with_key("did:webvh:{SCID}:localhost%3A8000", &key);
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(base_time), &doc, ¶ms, &key)
.await
.unwrap();
let actual_doc = state.log_entries.last().unwrap().get_state().clone();
state
.create_log_entry(
Some(base_time + Duration::seconds(1)),
&actual_doc,
&Parameters {
update_keys: Some(Arc::new(vec![])),
deactivated: Some(true),
..Default::default()
},
&key,
)
.await
.unwrap();
state.log_entries.push(LogEntryState {
log_entry: crate::log_entry::LogEntry::Spec1_0(
crate::log_entry::spec_1_0::LogEntry1_0 {
version_id: "3-ZZZZattackerappended".to_string(),
version_time: (base_time + Duration::seconds(2)).fixed_offset(),
parameters: crate::parameters::spec_1_0::Parameters1_0::default(),
state: actual_doc.clone(),
proof: vec![],
},
),
version_number: 3,
validated_parameters: Parameters::default(),
validation_status: LogEntryValidationStatus::NotValidated,
});
state.deactivated = false;
for entry in &mut state.log_entries {
entry.validation_status = LogEntryValidationStatus::NotValidated;
}
assert_eq!(state.log_entries.len(), 3);
let report = state.validate().unwrap();
assert_eq!(state.log_entries.len(), 2);
assert!(state.deactivated);
let Some(TruncationReason::PostDeactivation {
ref deactivated_at,
dropped_entries,
}) = report.truncated
else {
panic!("expected PostDeactivation, got {:?}", report.truncated);
};
assert_eq!(dropped_entries, 1);
assert!(deactivated_at.starts_with("2-"));
let err = report.assert_complete().unwrap_err();
assert!(err.to_string().contains("past the deactivation entry"));
}
#[test]
fn test_validate_invalid_first_entry_error() {
let mut state = DIDWebVHState::default();
state.log_entries.push(LogEntryState {
log_entry: crate::log_entry::LogEntry::Spec1_0(
crate::log_entry::spec_1_0::LogEntry1_0 {
version_id: "1-abc".to_string(),
version_time: Utc::now().fixed_offset(),
parameters: crate::parameters::spec_1_0::Parameters1_0::default(),
state: json!({}),
proof: vec![],
},
),
version_number: 1,
validated_parameters: Parameters::default(),
validation_status: LogEntryValidationStatus::NotValidated,
});
let err = state.validate().unwrap_err();
assert!(err.to_string().contains("No valid LogEntry found"));
}
async fn assert_ttl_produces_expiry(ttl: Option<u32>, expected_seconds: i64) {
let mut state = create_single_entry_state(ttl).await;
let _report = state.validate().unwrap();
let now = Utc::now().fixed_offset();
let diff = state.expires - now;
assert!(
diff.num_seconds() > (expected_seconds - 100) && diff.num_seconds() <= expected_seconds,
"Expected expiry ~{expected_seconds}s, got {}s",
diff.num_seconds()
);
}
#[tokio::test]
async fn test_validate_ttl_default() {
assert_ttl_produces_expiry(None, 3600).await;
}
#[tokio::test]
async fn test_validate_ttl_zero_defaults_to_3600() {
assert_ttl_produces_expiry(Some(0), 3600).await;
}
#[tokio::test]
async fn test_validate_ttl_custom() {
assert_ttl_produces_expiry(Some(7200), 7200).await;
}
#[test]
fn test_validate_no_log_entries_error() {
let mut state = DIDWebVHState::default();
let err = state.validate().unwrap_err();
assert!(err.to_string().contains("No validated LogEntries"));
}
}