use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Utc};
use dtg_credentials::{DTGCredential, VETTED_V1};
use serde_json::Value;
pub use dtg_credentials::IssuerScope;
use super::card::{CLOCK_SKEW, VerifiedVettingCard};
use super::{VettingError, did_of, digest, verify_attached_proof};
#[cfg(doc)]
use crate::protocols::vetting::VETTED_PREDICATE;
use crate::protocols::vetting::{CheckShape, VettedObjectValue};
use crate::trust_task_proof::TrustTaskVmResolver;
const WHAT: &str = "vetting statement";
#[derive(Debug, Clone)]
pub struct StatementDraft {
pub id: String,
pub issuer: String,
pub issuer_scope: IssuerScope,
pub subject: String,
pub value: VettedObjectValue,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub session: Value,
}
pub async fn sign_statement(draft: StatementDraft, signer: &Secret) -> Result<Value, VettingError> {
let malformed = |detail: String| VettingError::Malformed { what: WHAT, detail };
if did_of(&signer.id) != draft.issuer {
return Err(VettingError::WrongSigner {
what: WHAT,
role: "issuer",
});
}
draft
.value
.check_shape()
.map_err(|e| malformed(e.to_string()))?;
if draft.value.vetter_members().is_none() {
return Err(malformed(
"a vetter's statement carries identityCommitment, cardDigestMultibase and \
declaredRelationship"
.into(),
));
}
if draft.valid_until <= draft.valid_from {
return Err(VettingError::Expired(WHAT));
}
let value =
serde_json::to_value(&draft.value).map_err(|e| VettingError::Sign(e.to_string()))?;
let mut credential = DTGCredential::new_vetted_vsc(
draft.issuer,
draft.issuer_scope,
draft.subject,
value,
&draft.session,
draft.valid_from,
Some(draft.valid_until),
)
.map_err(|e| malformed(e.to_string()))?
.with_id(draft.id);
credential
.sign(signer, None)
.await
.map_err(|e| VettingError::Sign(e.to_string()))?;
serde_json::to_value(&credential).map_err(|e| VettingError::Sign(e.to_string()))
}
#[derive(Debug, Clone)]
pub struct VerifiedVettingStatement {
id: String,
issuer: String,
issuer_scope: IssuerScope,
subject: String,
value: VettedObjectValue,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
task_context: String,
task_digest_multibase: String,
has_status: bool,
digest_multibase: String,
}
impl VerifiedVettingStatement {
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
pub fn issuer_scope(&self) -> IssuerScope {
self.issuer_scope
}
#[must_use]
pub fn subject(&self) -> &str {
&self.subject
}
#[must_use]
pub fn value(&self) -> &VettedObjectValue {
&self.value
}
#[must_use]
pub fn valid_from(&self) -> DateTime<Utc> {
self.valid_from
}
#[must_use]
pub fn valid_until(&self) -> DateTime<Utc> {
self.valid_until
}
#[must_use]
pub fn task_context(&self) -> &str {
&self.task_context
}
#[must_use]
pub fn task_digest_multibase(&self) -> &str {
&self.task_digest_multibase
}
#[must_use]
pub fn has_status(&self) -> bool {
self.has_status
}
#[must_use]
pub fn digest_multibase(&self) -> &str {
&self.digest_multibase
}
pub fn check_against_card(&self, card: &VerifiedVettingCard) -> Result<(), VettingError> {
let c = card.card();
if self.subject != c.publisher.as_str() {
return Err(VettingError::Binding("subject"));
}
if self.value.community != c.community.as_str() {
return Err(VettingError::Binding("community"));
}
let Some(vetter) = self.value.vetter_members() else {
return Err(VettingError::Binding("identityCommitment"));
};
if vetter.identity_commitment != c.identity_commitment.as_str() {
return Err(VettingError::Binding("identityCommitment"));
}
if vetter.card_digest_multibase != card.digest_multibase() {
return Err(VettingError::Binding("cardDigestMultibase"));
}
Ok(())
}
pub fn check_against_session(&self, session: &Value) -> Result<(), VettingError> {
if self.value.vetter_members().is_none() {
return Err(VettingError::Binding("identityCommitment"));
}
if session.get("id").and_then(Value::as_str) != Some(self.task_context.as_str()) {
return Err(VettingError::Binding("taskContext"));
}
let expected = dtg_credentials::task_digest_multibase_json(session)
.map_err(|e| VettingError::Digest(e.to_string()))?;
let same = dtg_credentials::digests_match(&self.task_digest_multibase, &expected)
.map_err(|e| VettingError::Digest(e.to_string()))?;
if !same {
return Err(VettingError::Binding("taskDigestMultibase"));
}
Ok(())
}
}
pub async fn verify_statement(
value: &Value,
now: DateTime<Utc>,
resolver: &TrustTaskVmResolver,
) -> Result<VerifiedVettingStatement, VettingError> {
let malformed = |detail: String| VettingError::Malformed { what: WHAT, detail };
let credential = super::dtg_shape(value).map_err(|e| malformed(e.to_string()))?;
let statement = credential
.statement()
.ok_or_else(|| malformed("not a StatementCredential".into()))?;
if statement.predicate != VETTED_V1 {
return Err(malformed(format!(
"predicate `{}` is not {VETTED_V1}",
statement.predicate
)));
}
let object = statement
.object
.value()
.ok_or_else(|| malformed("no object.value".into()))?;
let vetted: VettedObjectValue = serde_json::from_value(object.clone())
.map_err(|e| malformed(format!("object.value: {e}")))?;
vetted
.check_shape()
.map_err(|e| malformed(format!("object.value: {e}")))?;
let id = credential
.id()
.ok_or_else(|| malformed("no id — a statement must be revocable by name".into()))?
.to_string();
let task_context = credential
.task_context()
.ok_or_else(|| malformed("no taskContext naming the session".into()))?
.to_string();
let task_digest_multibase = credential
.task_digest_multibase()
.ok_or_else(|| malformed("no taskDigestMultibase binding the session".into()))?
.to_string();
let valid_until = credential
.valid_until()
.ok_or_else(|| malformed("no validUntil — statements are bounded evidence".into()))?;
if credential.valid_from() > now + CLOCK_SKEW || now > valid_until {
return Err(VettingError::Expired(WHAT));
}
let issuer = credential.issuer().to_string();
let signer = verify_attached_proof(WHAT, value, "assertionMethod", resolver).await?;
if signer != issuer {
return Err(VettingError::WrongSigner {
what: WHAT,
role: "issuer",
});
}
Ok(VerifiedVettingStatement {
id,
issuer,
issuer_scope: credential.issuer_scope(),
subject: statement.id.clone(),
value: vetted,
valid_from: credential.valid_from(),
valid_until,
task_context,
task_digest_multibase,
has_status: credential.credential().credential_status.is_some(),
digest_multibase: digest(value)?,
})
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::protocols::vetting::{VETTED_PREDICATE, VettingMethod, VettingRelationship};
use crate::vetting::card::{
CardExpectations, sign_card,
tests::{CHALLENGE, COMMUNITY, draft},
verify_card,
};
use crate::vetting::test_support::{did, secret};
use chrono::Duration;
use serde_json::json;
pub(crate) fn vetted_value(commitment: &str, card_digest: &str) -> VettedObjectValue {
VettedObjectValue {
community: COMMUNITY.into(),
method: VettingMethod::Video,
document_classes: vec!["passport".try_into().unwrap()],
claims_verified: vec!["name.legal".try_into().unwrap()],
liveness_confirmed: true,
identity_commitment: Some(commitment.into()),
card_digest_multibase: Some(card_digest.into()),
declared_relationship: Some(VettingRelationship::CommunityColleague),
attestation_text_digest: None,
}
}
pub(crate) fn session_document(vetter: &str, applicant: &str) -> Value {
json!({
"id": "urn:uuid:session-1",
"type": "https://trusttasks.org/spec/vetting/session/0.1",
"threadId": "urn:uuid:session-1",
"parentThreadId": "urn:uuid:request-1",
"issuer": vetter,
"recipient": applicant,
"issuedAt": "2026-09-17T15:00:00Z",
"payload": { "method": "video" }
})
}
async fn verified_card(applicant: &Secret, vetter: &Secret) -> VerifiedVettingCard {
let now = Utc::now();
let signed = sign_card(draft(applicant, vetter, now), applicant)
.await
.unwrap();
let required = vec!["name.legal".to_string()];
let (a, v) = (did(applicant), did(vetter));
verify_card(
&signed,
&CardExpectations {
audience: &v,
publisher: &a,
community: COMMUNITY,
challenge: CHALLENGE,
domain: COMMUNITY,
required_claims: &required,
now,
},
&TrustTaskVmResolver::did_key_only(),
)
.await
.unwrap()
}
pub(crate) fn statement_draft(
vetter: &Secret,
subject: &str,
value: VettedObjectValue,
) -> StatementDraft {
let now = Utc::now();
StatementDraft {
id: "urn:uuid:statement-1".into(),
issuer: did(vetter),
issuer_scope: IssuerScope::Directed,
subject: subject.into(),
value,
valid_from: now,
valid_until: now + Duration::days(120),
session: session_document(&did(vetter), subject),
}
}
#[tokio::test]
async fn a_statement_verifies_and_binds_to_its_card_and_session() {
let (applicant, vetter) = (secret(1), secret(2));
let card = verified_card(&applicant, &vetter).await;
let e = vetted_value(&card.card().identity_commitment, card.digest_multibase());
let draft = statement_draft(&vetter, &did(&applicant), e);
let session = draft.session.clone();
let signed = sign_statement(draft, &vetter).await.unwrap();
assert_eq!(
signed["@context"],
json!([
dtg_credentials::W3C_VC_V2_CONTEXT,
dtg_credentials::DTG_CONTEXT_V1
])
);
assert_eq!(
signed["type"],
json!([
"VerifiableCredential",
"DTGCredential",
"StatementCredential"
])
);
assert_eq!(signed["issuerScope"], "directed");
assert_eq!(signed["taskContext"], "urn:uuid:session-1");
assert!(signed["taskDigestMultibase"].is_string());
assert_eq!(signed["credentialSubject"]["predicate"], VETTED_PREDICATE);
assert!(
signed["credentialSubject"]["object"]["value"]
.get("type")
.is_none()
);
let verified = verify_statement(&signed, Utc::now(), &TrustTaskVmResolver::did_key_only())
.await
.unwrap();
assert_eq!(verified.issuer(), did(&vetter));
assert_eq!(verified.issuer_scope(), IssuerScope::Directed);
assert_eq!(verified.value().method, VettingMethod::Video);
verified.check_against_card(&card).unwrap();
verified.check_against_session(&session).unwrap();
let mut other = session;
other["issuedAt"] = json!("2026-09-17T15:01:00Z");
assert!(matches!(
verified.check_against_session(&other),
Err(VettingError::Binding("taskDigestMultibase"))
));
}
#[tokio::test]
async fn an_altered_value_breaks_the_proof() {
let (applicant, vetter) = (secret(1), secret(2));
let e = vetted_value("zCommitment", "zCard");
let mut signed = sign_statement(statement_draft(&vetter, &did(&applicant), e), &vetter)
.await
.unwrap();
signed["credentialSubject"]["object"]["value"]["method"] = json!("inPerson");
let err = verify_statement(&signed, Utc::now(), &TrustTaskVmResolver::did_key_only())
.await
.unwrap_err();
assert!(matches!(err, VettingError::Proof { .. }), "{err:?}");
}
#[tokio::test]
async fn a_statement_over_a_different_card_is_caught() {
let (applicant, vetter) = (secret(1), secret(2));
let card = verified_card(&applicant, &vetter).await;
let e = vetted_value("zDifferentCommitment", card.digest_multibase());
let signed = sign_statement(statement_draft(&vetter, &did(&applicant), e), &vetter)
.await
.unwrap();
let verified = verify_statement(&signed, Utc::now(), &TrustTaskVmResolver::did_key_only())
.await
.unwrap();
assert!(matches!(
verified.check_against_card(&card),
Err(VettingError::Binding("identityCommitment"))
));
}
#[tokio::test]
async fn only_the_named_issuer_may_sign() {
let (applicant, vetter, other) = (secret(1), secret(2), secret(3));
let e = vetted_value("zC", "zD");
let err = sign_statement(statement_draft(&vetter, &did(&applicant), e), &other)
.await
.unwrap_err();
assert!(matches!(err, VettingError::WrongSigner { .. }));
}
#[tokio::test]
async fn an_expired_statement_is_refused() {
let (applicant, vetter) = (secret(1), secret(2));
let e = vetted_value("zC", "zD");
let signed = sign_statement(statement_draft(&vetter, &did(&applicant), e), &vetter)
.await
.unwrap();
let later = Utc::now() + Duration::days(200);
let err = verify_statement(&signed, later, &TrustTaskVmResolver::did_key_only())
.await
.unwrap_err();
assert!(matches!(err, VettingError::Expired(_)));
}
#[tokio::test]
async fn a_pairwise_vetter_cannot_issue() {
let (applicant, vetter) = (secret(1), secret(2));
let mut draft = statement_draft(&vetter, &did(&applicant), vetted_value("zC", "zD"));
draft.issuer_scope = IssuerScope::Pairwise;
assert!(matches!(
sign_statement(draft, &vetter).await.unwrap_err(),
VettingError::Malformed { .. }
));
}
#[tokio::test]
async fn a_statement_under_another_predicate_is_not_a_vetting_statement() {
let (applicant, vetter) = (secret(1), secret(2));
let now = Utc::now();
let mut vsc = DTGCredential::new_endorses_vsc(
did(&vetter),
IssuerScope::Directed,
did(&applicant),
serde_json::to_value(vetted_value("zC", "zD")).unwrap(),
now,
Some(now + Duration::days(30)),
)
.unwrap()
.with_id("urn:uuid:not-a-vetting".to_string());
vsc.sign(&vetter, None).await.unwrap();
let signed = serde_json::to_value(&vsc).unwrap();
assert!(matches!(
verify_statement(&signed, now, &TrustTaskVmResolver::did_key_only())
.await
.unwrap_err(),
VettingError::Malformed { .. }
));
}
async fn community_desk_check(community: &Secret, subject: &str) -> Value {
let now = Utc::now();
let mut value = vetted_value("zC", "zD");
value.community = did(community);
value.identity_commitment = None;
value.card_digest_multibase = None;
value.declared_relationship = None;
let request = json!({
"id": "urn:uuid:issue-request-1",
"type": "https://trusttasks.org/spec/vtc/endorsements/issue/0.1",
"issuer": "did:key:z6MkAdmin",
"recipient": did(community),
"issuedAt": "2026-10-01T09:30:00Z",
"payload": { "subjectDid": subject }
});
let mut vsc = DTGCredential::new_vetted_vsc(
did(community),
IssuerScope::Public,
subject.into(),
serde_json::to_value(&value).unwrap(),
&request,
now,
Some(now + Duration::days(365)),
)
.unwrap()
.with_id("urn:uuid:desk-check-1".to_string());
vsc.sign(community, None).await.unwrap();
serde_json::to_value(&vsc).unwrap()
}
#[tokio::test]
async fn a_community_statement_without_vetter_members_verifies_but_binds_no_session() {
let (community, applicant) = (secret(5), secret(1));
let signed = community_desk_check(&community, &did(&applicant)).await;
let value = &signed["credentialSubject"]["object"]["value"];
for member in [
"identityCommitment",
"cardDigestMultibase",
"declaredRelationship",
] {
assert!(value.get(member).is_none(), "{member}");
}
let verified = verify_statement(&signed, Utc::now(), &TrustTaskVmResolver::did_key_only())
.await
.unwrap();
assert_eq!(verified.issuer(), did(&community));
assert_eq!(verified.issuer_scope(), IssuerScope::Public);
assert!(verified.value().has_no_vetter_members());
assert_eq!(verified.value().vetter_members(), None);
assert!(matches!(
verified.check_against_session(&session_document(&did(&community), &did(&applicant))),
Err(VettingError::Binding("identityCommitment"))
));
}
#[tokio::test]
async fn vti_57_a_statement_carrying_a_proof_set_verifies() {
let (community, applicant) = (secret(6), secret(1));
let mut signed = community_desk_check(&community, &did(&applicant)).await;
let first = signed["proof"].clone();
let mut proofless = signed.clone();
proofless.as_object_mut().unwrap().remove("proof");
let second = affinidi_data_integrity::DataIntegrityProof::sign(
&proofless,
&community,
affinidi_data_integrity::SignOptions::new().with_proof_purpose("assertionMethod"),
)
.await
.unwrap();
signed["proof"] = json!([first, serde_json::to_value(second).unwrap()]);
let verified = verify_statement(&signed, Utc::now(), &TrustTaskVmResolver::did_key_only())
.await
.expect("a proof-set statement verifies");
assert_eq!(verified.issuer(), did(&community));
}
#[tokio::test]
async fn a_vetter_statement_without_the_vetter_members_is_refused() {
let (applicant, vetter) = (secret(1), secret(2));
let without = || {
let mut value = vetted_value("zC", "zD");
value.identity_commitment = None;
value.card_digest_multibase = None;
value.declared_relationship = None;
value
};
assert!(matches!(
sign_statement(
statement_draft(&vetter, &did(&applicant), without()),
&vetter
)
.await
.unwrap_err(),
VettingError::Malformed { .. }
));
let now = Utc::now();
let session = session_document(&did(&vetter), &did(&applicant));
let value = without();
let mut vsc = DTGCredential::new_vetted_vsc(
did(&vetter),
IssuerScope::Directed,
did(&applicant),
serde_json::to_value(&value).unwrap(),
&session,
now,
Some(now + Duration::days(30)),
)
.unwrap()
.with_id("urn:uuid:no-card".to_string());
vsc.sign(&vetter, None).await.unwrap();
let signed = serde_json::to_value(&vsc).unwrap();
let verified = verify_statement(&signed, now, &TrustTaskVmResolver::did_key_only())
.await
.unwrap();
assert!(matches!(
verified.check_against_session(&session),
Err(VettingError::Binding("identityCommitment"))
));
}
#[tokio::test]
async fn a_pre_v1_statement_is_refused() {
let legacy = json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"id": "urn:uuid:legacy",
"type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
"issuer": "did:key:z6MkVetter",
"validFrom": "2026-09-01T00:00:00Z",
"validUntil": "2027-01-01T00:00:00Z",
"taskContext": "urn:uuid:session-1",
"credentialSubject": { "id": "did:key:z6MkApplicant", "endorsement": {} }
});
assert!(matches!(
verify_statement(&legacy, Utc::now(), &TrustTaskVmResolver::did_key_only())
.await
.unwrap_err(),
VettingError::Malformed { .. }
));
}
}