use crate::{
DIDWebVHError,
log_entry::{PublicKey, enforce_witness_proof_shape},
log_entry_state::LogEntryState,
witness::{WitnessVerifyOptions, proofs::WitnessProofCollection},
};
use affinidi_data_integrity::VerifyOptions;
use serde_json::json;
use tracing::{debug, warn};
impl WitnessProofCollection {
pub fn validate_log_entry(
&mut self,
log_entry: &LogEntryState,
highest_version_number: u32,
options: &WitnessVerifyOptions,
) -> Result<(), DIDWebVHError> {
let Some(witnesses) = &log_entry.validated_parameters.active_witness else {
return Ok(());
};
let Some(witness_nodes) = witnesses.witnesses() else {
return Ok(());
};
let version_number = log_entry.log_entry.get_version_id_fields()?.0;
let mut valid_proofs = 0;
for w in witness_nodes {
let did_key_vm = w.as_did_key();
let Some((proof_version_id, oldest_id, proof)) = self.witness_version.get(&did_key_vm)
else {
debug!("No Witness proofs exist for witness ({})", w.id);
continue;
};
debug!(
"oldest_id ({}) > highest_version_number ({})",
oldest_id, highest_version_number
);
if oldest_id > &highest_version_number {
debug!(
"LogEntry ({}): Skipping witness proof from {} (oldest: {oldest_id}, highest: {})",
log_entry.get_version_id(),
w.id,
highest_version_number
);
continue;
}
debug!("oldest_id ({oldest_id}) vs. version_number ({version_number})",);
match oldest_id.cmp(&version_number) {
std::cmp::Ordering::Greater => {
enforce_witness_proof_shape(proof, options)?;
proof
.verify_with_public_key(
&json!({ "versionId": &**proof_version_id }),
proof.get_public_key_bytes()?.as_slice(),
VerifyOptions::new(),
)
.map_err(|e| {
DIDWebVHError::WitnessProofError(format!(
"LogEntry ({}): Witness proof for later version ({}) failed verification: {}",
log_entry.get_version_id(),
proof_version_id,
e
))
})?;
debug!(
"LogEntry ({}): later witness proof from {} (for {oldest_id}) verified ok",
log_entry.get_version_id(),
w.id,
);
valid_proofs += 1;
continue;
}
std::cmp::Ordering::Equal => {
log_entry
.log_entry
.validate_witness_proof(proof, options)
.map_err(|e| {
DIDWebVHError::WitnessProofError(format!(
"LogEntry ({}): Witness proof validation failed: {}",
log_entry.get_version_id(),
e
))
})?;
valid_proofs += 1;
debug!(
"LogEntry ({}): Witness proof ({}) verified ok",
log_entry.get_version_id(),
w.id
);
}
std::cmp::Ordering::Less => {
debug!(
"LogEntry ({}): older witness proof from {} (for {oldest_id}) does not approve current entry per spec; not counted toward threshold",
log_entry.get_version_id(),
w.id,
);
continue;
}
}
}
let Some(threshold) = witnesses.threshold() else {
return Err(DIDWebVHError::ValidationError(
"Witness threshold not defined when witnessing seems to be enabled!".to_string(),
));
};
if valid_proofs < threshold {
warn!(
"LogEntry ({}): Witness threshold ({threshold}) not met. Only ({valid_proofs} valid proofs!",
log_entry.get_version_id(),
);
Err(DIDWebVHError::WitnessProofError(format!(
"Witness proof threshold ({threshold}) was not met. Only ({valid_proofs}) proofs were validated",
)))
} else {
debug!(
"LogEntry ({}): Witness proofs fully passed",
log_entry.get_version_id()
);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
use chrono::Utc;
use serde_json::json;
use crate::{
Multibase,
log_entry::{LogEntry, spec_1_0::LogEntry1_0},
log_entry_state::{LogEntryState, LogEntryValidationStatus},
parameters::{Parameters, spec_1_0::Parameters1_0},
witness::proofs::WitnessProofCollection,
witness::{Witness, WitnessVerifyOptions, Witnesses},
};
#[test]
fn test_no_witnesses_configured() {
let mut proofs = WitnessProofCollection::default();
let log_entry = LogEntryState {
version_number: 1,
log_entry: LogEntry::Spec1_0(LogEntry1_0 {
proof: vec![],
parameters: Parameters1_0::default(),
version_id: "1-abcd".to_string(),
version_time: Utc::now().fixed_offset(),
state: json!({}),
}),
validated_parameters: Parameters {
active_witness: None,
..Default::default()
},
validation_status: LogEntryValidationStatus::Ok,
};
proofs
.validate_log_entry(&log_entry, 1, &WitnessVerifyOptions::new())
.expect("Couldn't validate witness proofs");
}
fn make_witnessed_entry(version_id: &str, witnesses: Witnesses) -> LogEntryState {
LogEntryState {
version_number: version_id
.split_once('-')
.map(|(n, _)| n.parse().unwrap())
.unwrap_or(1),
log_entry: LogEntry::Spec1_0(LogEntry1_0 {
proof: vec![],
parameters: Parameters1_0::default(),
version_id: version_id.to_string(),
version_time: Utc::now().fixed_offset(),
state: json!({}),
}),
validated_parameters: Parameters {
active_witness: Some(Arc::new(witnesses)),
..Default::default()
},
validation_status: LogEntryValidationStatus::Ok,
}
}
#[test]
fn test_witnesses_empty_variant_returns_ok() {
let mut proofs = WitnessProofCollection::default();
let entry = make_witnessed_entry("1-abcd", Witnesses::Empty {});
proofs
.validate_log_entry(&entry, 1, &WitnessVerifyOptions::new())
.expect("Empty witnesses should return Ok");
}
#[test]
fn test_witness_proof_missing_for_witness() {
let mut proofs = WitnessProofCollection::default();
let witnesses = Witnesses::Value {
threshold: 1,
witnesses: vec![
Witness {
id: Multibase::new("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7lL8N8AC4Pp6"),
},
Witness {
id: Multibase::new("z6MkqUa1LbqZ7EpevqrFC7XHAWM8CE49AKFWVjyu543NfVAp"),
},
],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
let err = proofs
.validate_log_entry(&entry, 1, &WitnessVerifyOptions::new())
.unwrap_err();
assert!(err.to_string().contains("threshold"));
}
#[test]
fn test_witness_proof_from_future_skipped() {
use crate::test_utils::make_test_proof;
let mut proofs = WitnessProofCollection::default();
let raw_key = "z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7lL8N8AC4Pp6";
let witness_id = format!("did:key:{raw_key}");
let vm = format!("{witness_id}#{raw_key}");
let proof = make_test_proof(&vm);
proofs.add_proof("5-future", &proof, false).unwrap();
let witnesses = Witnesses::Value {
threshold: 1,
witnesses: vec![Witness {
id: Multibase::new(&witness_id),
}],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
let err = proofs
.validate_log_entry(&entry, 1, &WitnessVerifyOptions::new())
.unwrap_err();
assert!(err.to_string().contains("threshold"));
}
#[tokio::test]
async fn test_witness_proof_older_than_current_counts() {
let mut proofs = WitnessProofCollection::default();
let secret = affinidi_secrets_resolver::secrets::Secret::generate_ed25519(None, None);
let pk = secret.get_public_keymultibase().unwrap();
let mut witness_secret = secret.clone();
witness_secret.id = format!("did:key:{pk}#{pk}");
let signed_proof = DataIntegrityProof::sign(
&json!({"versionId": "3-hash"}),
&witness_secret,
SignOptions::new(),
)
.await
.unwrap();
proofs.add_proof("3-hash", &signed_proof, false).unwrap();
let witnesses = Witnesses::Value {
threshold: 1,
witnesses: vec![Witness {
id: Multibase::new(format!("did:key:{pk}")),
}],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
proofs
.validate_log_entry(&entry, 5, &WitnessVerifyOptions::new())
.expect("Older proof should still count as valid");
}
#[test]
fn test_witness_proof_later_version_forged_rejected() {
use crate::test_utils::make_test_proof;
let mut proofs = WitnessProofCollection::default();
let raw_key = "z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7lL8N8AC4Pp6";
let witness_id = format!("did:key:{raw_key}");
let vm = format!("{witness_id}#{raw_key}");
let proof = make_test_proof(&vm);
proofs.add_proof("3-hash", &proof, false).unwrap();
let witnesses = Witnesses::Value {
threshold: 1,
witnesses: vec![Witness {
id: Multibase::new(&witness_id),
}],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
let err = proofs
.validate_log_entry(&entry, 5, &WitnessVerifyOptions::new())
.expect_err("forged later-version proof must not satisfy threshold");
assert!(err.to_string().contains("Witness"));
}
#[tokio::test]
async fn test_witness_threshold_met() {
let mut proofs = WitnessProofCollection::default();
let secret = affinidi_secrets_resolver::secrets::Secret::generate_ed25519(None, None);
let pk = secret.get_public_keymultibase().unwrap();
let mut witness_secret = secret.clone();
witness_secret.id = format!("did:key:{pk}#{pk}");
let signed_proof = DataIntegrityProof::sign(
&json!({"versionId": "1-abcd"}),
&witness_secret,
SignOptions::new(),
)
.await
.unwrap();
proofs.add_proof("1-abcd", &signed_proof, false).unwrap();
let witness_id = format!("did:key:{pk}");
let witnesses = Witnesses::Value {
threshold: 1,
witnesses: vec![Witness {
id: Multibase::new(witness_id),
}],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
proofs
.validate_log_entry(&entry, 1, &WitnessVerifyOptions::new())
.expect("Threshold should be met");
}
#[test]
fn test_witness_threshold_not_met() {
let mut proofs = WitnessProofCollection::default();
let witnesses = Witnesses::Value {
threshold: 2,
witnesses: vec![
Witness {
id: Multibase::new("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7lL8N8AC4Pp6"),
},
Witness {
id: Multibase::new("z6MkqUa1LbqZ7EpevqrFC7XHAWM8CE49AKFWVjyu543NfVAp"),
},
],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
let err = proofs
.validate_log_entry(&entry, 1, &WitnessVerifyOptions::new())
.unwrap_err();
assert!(err.to_string().contains("threshold"));
}
#[test]
fn test_witness_no_threshold_error() {
let mut proofs = WitnessProofCollection::default();
let witnesses = Witnesses::Value {
threshold: 0,
witnesses: vec![Witness {
id: Multibase::new("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7lL8N8AC4Pp6"),
}],
};
let entry = make_witnessed_entry("1-abcd", witnesses);
proofs
.validate_log_entry(&entry, 1, &WitnessVerifyOptions::new())
.expect("0 threshold with 0 proofs should pass");
}
}