use serde_json::Value as JsonValue;
use affinidi_data_integrity::{DataIntegrityProof, VerifyOptions, crypto_suites::CryptoSuite};
use super::purpose::{ProofPurpose, PurposeBound, PurposeVmResolver};
pub fn proof_set(proof_value: &JsonValue) -> Result<Vec<DataIntegrityProof>, String> {
let raw: Vec<&JsonValue> = match proof_value {
JsonValue::Array(items) => items.iter().collect(),
single => vec![single],
};
if raw.is_empty() {
return Err(
"proof block is an empty array; a document with no proof is unsigned, \
which is a different thing from one whose proofs did not verify"
.to_string(),
);
}
let mut proofs = Vec::with_capacity(raw.len());
for (i, v) in raw.iter().enumerate() {
if unsupported_suite(v) {
continue;
}
proofs.push(
serde_json::from_value::<DataIntegrityProof>((*v).clone()).map_err(|e| {
format!("proof {i} did not parse as a Data Integrity proof: {e}")
})?,
);
}
if proofs.is_empty() {
return Err(
"no proof uses a cryptosuite this verifier implements, so none can be checked"
.to_string(),
);
}
Ok(proofs)
}
fn unsupported_suite(v: &JsonValue) -> bool {
v.get("type").and_then(JsonValue::as_str) == Some("DataIntegrityProof")
&& v.get("cryptosuite")
.and_then(JsonValue::as_str)
.is_some_and(|suite| CryptoSuite::try_from(suite).is_err())
}
pub fn require_suites(
proofs: &[DataIntegrityProof],
required: &[CryptoSuite],
) -> Result<(), String> {
match required
.iter()
.find(|suite| !proofs.iter().any(|p| p.cryptosuite == **suite))
{
None => Ok(()),
Some(missing) => Err(format!(
"this verifier requires a {missing} proof, and the document carries none"
)),
}
}
#[must_use]
pub fn proof_signer_did(proof: &DataIntegrityProof) -> &str {
proof
.verification_method
.split('#')
.next()
.unwrap_or_default()
}
pub async fn verify_one<S>(
proof: &DataIntegrityProof,
doc: &S,
resolver: &(dyn PurposeVmResolver + '_),
expected: ProofPurpose,
) -> Result<(), String>
where
S: serde::Serialize + Sync,
{
let bound = PurposeBound::for_proof(resolver, proof).map_err(|e| e.to_string())?;
if bound.purpose() != expected {
return Err(format!(
"proofPurpose is {}, but this document is relied on for {expected}",
bound.purpose()
));
}
proof
.verify(doc, &bound, VerifyOptions::new())
.await
.map_err(|e| e.to_string())
}
pub fn accept_all(outcomes: &[(String, Result<(), String>)]) -> Result<String, String> {
let Some((first, _)) = outcomes.first() else {
return Err("no proof to verify; a document with no proof is unsigned".to_string());
};
let reasons: Vec<String> = outcomes
.iter()
.enumerate()
.filter_map(|(i, (_, r))| r.as_ref().err().map(|e| format!("proof {i}: {e}")))
.collect();
if !reasons.is_empty() {
return Err(format!(
"{} of {} proofs did not verify, and every proof present must — {}",
reasons.len(),
outcomes.len(),
reasons.join("; ")
));
}
if outcomes.iter().any(|(did, _)| did != first) {
return Err(
"proofs verify for two different issuers; a document signed by more than one \
party cannot be reported as verified for one of them"
.to_string(),
);
}
Ok(first.clone())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ProofSetError {
#[error("document has no proof")]
Missing,
#[error("signed document is not a JSON object")]
NotAnObject,
#[error("proof block is unreadable: {0}")]
Unreadable(String),
#[error("proof set did not verify: {0}")]
NotVerified(String),
}
#[derive(Debug, Clone)]
pub struct VerifiedProofSet {
signer: String,
proofs: Vec<DataIntegrityProof>,
}
impl VerifiedProofSet {
#[must_use]
pub fn signer(&self) -> &str {
&self.signer
}
#[must_use]
pub fn proofs(&self) -> &[DataIntegrityProof] {
&self.proofs
}
#[must_use]
pub fn into_signer(self) -> String {
self.signer
}
}
pub async fn verify_proof_set(
signed: &JsonValue,
expected: ProofPurpose,
resolver: &(dyn PurposeVmResolver + '_),
) -> Result<VerifiedProofSet, ProofSetError> {
let map = signed.as_object().ok_or(ProofSetError::NotAnObject)?;
let proof_value = map.get("proof").ok_or(ProofSetError::Missing)?;
let proofs = proof_set(proof_value).map_err(ProofSetError::Unreadable)?;
let mut unsigned = map.clone();
unsigned.remove("proof");
let unsigned = JsonValue::Object(unsigned);
let mut outcomes = Vec::with_capacity(proofs.len());
for proof in &proofs {
let did = proof_signer_did(proof).to_string();
let r = verify_one(proof, &unsigned, resolver, expected).await;
outcomes.push((did, r));
}
let signer = accept_all(&outcomes).map_err(ProofSetError::NotVerified)?;
Ok(VerifiedProofSet { signer, proofs })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trust_task_proof::TrustTaskVmResolver;
use affinidi_data_integrity::{DataIntegrityError, ResolvedKey, SignOptions};
use affinidi_secrets_resolver::secrets::Secret;
use serde_json::json;
fn a_proof(vm: &str) -> JsonValue {
json!({
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2026-01-01T00:00:00Z",
"verificationMethod": vm,
"proofPurpose": "assertionMethod",
"proofValue": "z2V1p3vLnQ8kZ9YbW3xJ5tR7mN4qS6dF8gH1jK2lM3nP",
})
}
#[test]
fn a_bare_object_is_one_proof() {
let set = proof_set(&a_proof("did:example:alice#key-0")).expect("parses");
assert_eq!(set.len(), 1);
}
#[test]
fn an_array_is_a_proof_set() {
let set = proof_set(&json!([
a_proof("did:example:alice#key-0"),
a_proof("did:example:alice#key-pq"),
]))
.expect("parses");
assert_eq!(set.len(), 2);
}
#[test]
fn a_malformed_proof_is_identified_by_index() {
let err = proof_set(&json!([
a_proof("did:example:alice#key-0"),
json!({"type": "nope"})
]))
.expect_err("the second proof is not a Data Integrity proof");
assert!(err.contains("proof 1"), "unexpected: {err}");
}
#[test]
fn an_unsupported_suite_is_skipped() {
let mut pq = a_proof("did:example:alice#key-pq");
pq["cryptosuite"] = json!("example-future-2030");
let set = proof_set(&json!([a_proof("did:example:alice#key-0"), pq])).expect("parses");
assert_eq!(set.len(), 1);
assert_eq!(set[0].verification_method, "did:example:alice#key-0");
}
#[test]
fn only_unsupported_suites_is_refused() {
let mut pq = a_proof("did:example:alice#key-pq");
pq["cryptosuite"] = json!("example-future-2030");
let err = proof_set(&pq).expect_err("nothing to check");
assert!(err.contains("no proof uses a cryptosuite"), "{err}");
}
#[test]
fn a_proof_with_no_suite_is_not_skipped() {
let mut p = a_proof("did:example:alice#key-0");
p.as_object_mut().unwrap().remove("cryptosuite");
assert!(proof_set(&json!([a_proof("did:example:alice#key-0"), p])).is_err());
}
#[test]
fn a_required_suite_must_be_present() {
let set = proof_set(&a_proof("did:example:alice#key-0")).expect("parses");
require_suites(&set, &[CryptoSuite::EddsaJcs2022]).expect("present");
let err = require_suites(
&set,
&[CryptoSuite::EddsaJcs2022, CryptoSuite::EcdsaJcs2019],
)
.expect_err("ecdsa absent");
assert!(err.contains("ecdsa-jcs-2019"), "{err}");
}
#[test]
fn an_empty_array_says_what_it_is() {
let err = proof_set(&json!([])).expect_err("empty");
assert!(err.contains("unsigned"), "unexpected: {err}");
}
#[test]
fn one_failing_proof_refuses_the_set() {
let did = "did:example:alice".to_string();
let err = accept_all(&[
(
did.clone(),
Err("key not listed under assertionMethod".into()),
),
(did.clone(), Ok(())),
])
.expect_err("a failing proof refuses the set");
assert!(err.contains("1 of 2 proofs"), "unexpected: {err}");
assert!(err.contains("assertionMethod"), "unexpected: {err}");
}
#[test]
fn every_proof_verifying_is_accepted() {
let did = "did:example:alice".to_string();
let out = accept_all(&[(did.clone(), Ok(())), (did.clone(), Ok(()))]).expect("all verify");
assert_eq!(out, did);
}
#[test]
fn an_empty_outcome_set_is_refused() {
assert!(accept_all(&[]).is_err());
}
#[test]
fn no_verifying_proof_reports_every_reason() {
let err = accept_all(&[
("did:example:alice".into(), Err("bad signature".into())),
("did:example:alice".into(), Err("unsupported suite".into())),
])
.expect_err("nothing verified");
assert!(err.contains("bad signature"), "unexpected: {err}");
assert!(err.contains("unsupported suite"), "unexpected: {err}");
}
#[test]
fn proofs_from_two_issuers_are_refused_even_when_both_verify() {
let err = accept_all(&[
("did:example:alice".into(), Ok(())),
("did:example:mallory".into(), Ok(())),
])
.expect_err("two issuers cannot be reported as one");
assert!(err.contains("two different issuers"), "unexpected: {err}");
assert!(
!err.contains("mallory"),
"no identifiers in the refusal: {err}"
);
}
fn did_key_signer(seed: u8) -> (String, Secret) {
let probe = Secret::generate_ed25519(None, Some(&[seed; 32]));
let mb = probe.get_public_keymultibase().expect("multikey");
let vm = format!("did:key:{mb}#{mb}");
(
vm.clone(),
Secret::generate_ed25519(Some(&vm), Some(&[seed; 32])),
)
}
async fn signed_proof(secret: &Secret, purpose: &str, doc: &JsonValue) -> DataIntegrityProof {
DataIntegrityProof::sign(doc, secret, SignOptions::new().with_proof_purpose(purpose))
.await
.expect("sign")
}
fn doc() -> JsonValue {
json!({ "type": ["VerifiableCredential"], "credentialSubject": { "id": "did:example:s" } })
}
#[tokio::test]
async fn a_did_key_proof_verifies_for_both_purposes() {
let (_, secret) = did_key_signer(0x31);
let resolver = TrustTaskVmResolver::did_key_only();
for (purpose, expected) in [
("assertionMethod", ProofPurpose::AssertionMethod),
("authentication", ProofPurpose::Authentication),
] {
let proof = signed_proof(&secret, purpose, &doc()).await;
verify_one(&proof, &doc(), &resolver, expected)
.await
.unwrap_or_else(|e| panic!("{purpose}: {e}"));
}
}
#[tokio::test]
async fn a_proof_for_another_purpose_is_refused() {
let (_, secret) = did_key_signer(0x32);
let resolver = TrustTaskVmResolver::did_key_only();
let proof = signed_proof(&secret, "authentication", &doc()).await;
let err = verify_one(&proof, &doc(), &resolver, ProofPurpose::AssertionMethod)
.await
.expect_err("wrong purpose");
assert!(err.contains("relied on for assertionMethod"), "{err}");
let proof = signed_proof(&secret, "keyAgreement", &doc()).await;
assert!(
verify_one(&proof, &doc(), &resolver, ProofPurpose::AssertionMethod)
.await
.is_err()
);
}
#[tokio::test]
async fn a_proof_set_with_one_invalid_proof_is_refused() {
let (vm, secret) = did_key_signer(0x34);
let good = signed_proof(&secret, "assertionMethod", &doc()).await;
let mut bad = good.clone();
bad.proof_value = Some(
signed_proof(&secret, "assertionMethod", &json!({"other": 1}))
.await
.proof_value
.expect("proofValue"),
);
let resolver = TrustTaskVmResolver::did_key_only();
let did = vm.split('#').next().unwrap().to_string();
let mut outcomes = Vec::new();
for p in [&good, &bad] {
outcomes.push((
did.clone(),
verify_one(p, &doc(), &resolver, ProofPurpose::AssertionMethod).await,
));
}
assert!(outcomes[0].1.is_ok(), "{:?}", outcomes[0].1);
let err = accept_all(&outcomes).expect_err("one invalid proof refuses the set");
assert!(err.contains("1 of 2 proofs"), "{err}");
}
const ISSUER: &str = "did:web:issuer.example";
struct FixedKeys(Vec<(String, ResolvedKey)>);
#[async_trait::async_trait]
impl PurposeVmResolver for FixedKeys {
async fn resolve_vm_for_purpose(
&self,
vm: &str,
_purpose: ProofPurpose,
) -> Result<ResolvedKey, DataIntegrityError> {
self.0
.iter()
.find(|(id, _)| id == vm)
.map(|(_, k)| k.clone())
.ok_or_else(|| DataIntegrityError::Resolver(format!("unknown method {vm}")))
}
}
fn key_of(secret: &Secret) -> (String, ResolvedKey) {
(
secret.id.clone(),
ResolvedKey::new(secret.get_key_type(), secret.get_public_bytes().to_vec()),
)
}
fn hybrid_keys() -> (Secret, Secret) {
(
Secret::generate_ed25519(Some(&format!("{ISSUER}#key-0")), Some(&[0x41; 32])),
Secret::generate_ml_dsa_44(Some(&format!("{ISSUER}#key-2")), Some(&[0x42; 32])),
)
}
async fn sign_set(mut doc: JsonValue, secrets: &[&Secret], purpose: &str) -> JsonValue {
let signers: Vec<&dyn affinidi_data_integrity::signer::Signer> = secrets
.iter()
.map(|s| *s as &dyn affinidi_data_integrity::signer::Signer)
.collect();
let proofs = DataIntegrityProof::sign_multi(
&doc,
&signers,
SignOptions::new().with_proof_purpose(purpose),
)
.await
.expect("sign_multi");
doc["proof"] = serde_json::to_value(&proofs).unwrap();
doc
}
#[tokio::test]
async fn vti_44_a_hybrid_proof_set_verifies() {
let (ed, pq) = hybrid_keys();
let resolver = FixedKeys(vec![key_of(&ed), key_of(&pq)]);
let signed = sign_set(doc(), &[&ed, &pq], "assertionMethod").await;
assert!(signed["proof"].is_array());
let verified = verify_proof_set(&signed, ProofPurpose::AssertionMethod, &resolver)
.await
.expect("both proofs verify");
assert_eq!(verified.signer(), ISSUER);
assert_eq!(verified.proofs().len(), 2);
require_suites(
verified.proofs(),
&[CryptoSuite::EddsaJcs2022, CryptoSuite::MlDsa44Jcs2024],
)
.expect("both suites were checked");
}
#[tokio::test]
async fn vti_44_one_tampered_proof_refuses_the_set() {
let (ed, pq) = hybrid_keys();
let resolver = FixedKeys(vec![key_of(&ed), key_of(&pq)]);
let signed = sign_set(doc(), &[&ed, &pq], "assertionMethod").await;
let other = sign_set(json!({"other": 1}), &[&ed, &pq], "assertionMethod").await;
let mut tampered = signed.clone();
tampered["proof"][1]["proofValue"] = other["proof"][1]["proofValue"].clone();
let err = verify_proof_set(&tampered, ProofPurpose::AssertionMethod, &resolver)
.await
.expect_err("one bad proof refuses the set");
assert!(
matches!(&err, ProofSetError::NotVerified(d) if d.contains("1 of 2 proofs")),
"{err:?}"
);
let mut altered = signed;
altered["credentialSubject"]["id"] = json!("did:example:mallory");
assert!(
verify_proof_set(&altered, ProofPurpose::AssertionMethod, &resolver)
.await
.is_err()
);
}
#[tokio::test]
async fn vti_44_a_proof_from_another_signer_refuses_the_set() {
let (ed, _) = hybrid_keys();
let stranger =
Secret::generate_ed25519(Some("did:web:stranger.example#key-0"), Some(&[0x43; 32]));
let resolver = FixedKeys(vec![key_of(&ed), key_of(&stranger)]);
let signed = sign_set(doc(), &[&ed, &stranger], "assertionMethod").await;
let err = verify_proof_set(&signed, ProofPurpose::AssertionMethod, &resolver)
.await
.expect_err("two signers");
assert!(
matches!(&err, ProofSetError::NotVerified(d) if d.contains("two different issuers")),
"{err:?}"
);
}
#[tokio::test]
async fn vti_44_a_single_proof_object_still_verifies() {
let (vm, secret) = did_key_signer(0x35);
let mut signed = doc();
signed["proof"] =
serde_json::to_value(signed_proof(&secret, "assertionMethod", &doc()).await).unwrap();
assert!(signed["proof"].is_object());
let resolver = TrustTaskVmResolver::did_key_only();
let verified = verify_proof_set(&signed, ProofPurpose::AssertionMethod, &resolver)
.await
.expect("verifies");
assert_eq!(verified.signer(), vm.split('#').next().unwrap());
assert!(
verify_proof_set(&signed, ProofPurpose::Authentication, &resolver)
.await
.is_err()
);
}
#[tokio::test]
async fn an_unsigned_document_is_refused() {
let resolver = TrustTaskVmResolver::did_key_only();
assert_eq!(
verify_proof_set(&doc(), ProofPurpose::AssertionMethod, &resolver)
.await
.unwrap_err(),
ProofSetError::Missing
);
assert_eq!(
verify_proof_set(&json!([1]), ProofPurpose::AssertionMethod, &resolver)
.await
.unwrap_err(),
ProofSetError::NotAnObject
);
}
}