pub mod auto_grant;
pub mod challenge_refusal;
pub const HIDDEN_VETTING_MEMBER: &str = "hiddenVetting";
pub fn redact_hidden_submission(extensions: &mut JsonValue) -> bool {
let Some(obj) = extensions.as_object_mut() else {
return false;
};
let Some(proof) = obj.get(HIDDEN_VETTING_MEMBER) else {
return false;
};
let bytes = serde_json::to_vec(proof).unwrap_or_default();
let suite = proof
.get("suite")
.and_then(JsonValue::as_str)
.unwrap_or("unknown")
.to_string();
let digest = hex::encode(<sha2::Sha256 as sha2::Digest>::digest(&bytes));
obj.insert(
HIDDEN_VETTING_MEMBER.to_string(),
serde_json::json!({
"redacted": true,
"suite": suite,
"bytes": bytes.len(),
"sha256": digest,
}),
);
true
}
#[cfg(feature = "vetting-pcs")]
pub mod pcs;
#[cfg(feature = "vetting-pcs")]
pub mod pcs_challenge;
#[cfg(feature = "vetting-pcs")]
pub mod pcs_event;
#[cfg(feature = "vetting-pcs")]
pub mod pcs_issue;
#[cfg(feature = "vetting-pcs")]
pub mod pcs_tasks;
pub mod profiles;
pub mod revocation;
pub mod vetters;
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::warn;
use vta_sdk::protocols::vetting::{
VETTED_PREDICATE, VettingRequirements, VettingRequirementsInvitation,
};
use vta_sdk::vetting::requirements::{StatementFacts, evaluate};
use vta_sdk::vetting::statement::verify_statement;
use vti_common::error::AppError;
use crate::endorsements::{VETTER_GRANT_ROW_TYPE, endorsements_for_subject};
use crate::members::storage::get_member;
use crate::routes::join_requests::manifest::ServedCriterion;
use crate::server::AppState;
pub const NEED_VETTING: &str = "vetting";
pub const NEED_INVITATION: &str = "vetting:invitation";
pub const NEED_HIDDEN_PREFIX: &str = "vetting:hidden:";
pub const HIDDEN_NO_PROOF: &str = "no-proof";
pub const HIDDEN_UNSUPPORTED: &str = "unsupported";
pub const NAMED_ON_HIDDEN: &str = "named-statement-on-hidden-criterion";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VettingFacts {
pub criterion_id: String,
pub requirements_digest: String,
pub applicant_digest_matches: bool,
pub statements: Vec<VettingStatementFact>,
pub distinct_counted_vetters: u32,
pub by_method: BTreeMap<String, u32>,
pub commitments_consistent: bool,
pub independence_ok: bool,
pub invitation_required: bool,
pub satisfied: bool,
pub needs: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VettingStatementFact {
pub id: Option<String>,
pub issuer: Option<String>,
pub verified: bool,
pub eligible: bool,
pub revoked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_relationship: Option<String>,
pub counted: bool,
#[serde(default)]
pub failures: Vec<String>,
}
#[derive(Debug)]
pub enum FactsError {
Challenge(challenge_refusal::ChallengeRefusal),
App(AppError),
}
impl From<AppError> for FactsError {
fn from(e: AppError) -> Self {
Self::App(e)
}
}
impl From<FactsError> for AppError {
fn from(e: FactsError) -> Self {
match e {
FactsError::Challenge(r) => r.into(),
FactsError::App(e) => e,
}
}
}
#[cfg_attr(not(feature = "vetting-pcs"), allow(unused_variables))]
pub async fn vetting_facts(
state: &AppState,
applicant_did: &str,
vp: &JsonValue,
extensions: &JsonValue,
criterion: &ServedCriterion,
cited: bool,
now: DateTime<Utc>,
) -> Result<Option<VettingFacts>, FactsError> {
let Some(requirements) = criterion.stored.vetting.clone() else {
return Ok(None);
};
let selected = Selected {
criterion_id: criterion.stored.id.clone(),
requirements,
digest: criterion.digest.clone(),
applicant_digest_matches: cited,
hidden_vetting: criterion.stored.hidden_vetting.clone(),
};
let requirements = &selected.requirements;
let community_did = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_default();
#[cfg(feature = "vetting-pcs")]
if let Some(facts) = hidden_facts(
state,
&community_did,
applicant_did,
&selected,
requirements,
extensions,
now,
)
.await?
{
return Ok(Some(facts));
}
if selected.hidden_vetting.is_some() {
return Ok(Some(hidden_without_proof(applicant_did, selected, vp, now)));
}
let resolver = state.trust_task_vm_resolver();
let accepted = crate::endorsement_types::accept_list(&state.endorsement_types_ks).await?;
let mut to_count = Vec::new();
let mut statements = Vec::new();
for vc in vetting_credentials(vp, &requirements.statement_type) {
let id = vc.get("id").and_then(JsonValue::as_str).map(str::to_string);
let issuer = issuer_of(vc);
let verified = match verify_statement(vc, now, &resolver).await {
Ok(v) => v,
Err(e) => {
warn!(
applicant = %applicant_did,
statement = id.as_deref().unwrap_or("<no id>"),
error = %e,
cause = e.cause().unwrap_or(""),
"vetting statement did not verify — not counted"
);
statements.push(VettingStatementFact {
id,
issuer,
verified: false,
eligible: false,
revoked: false,
method: None,
declared_relationship: None,
counted: false,
failures: vec!["unverified".into()],
});
continue;
}
};
let vetted = verified.value();
let mut failures = Vec::new();
if verified.subject() != applicant_did {
failures.push("subject-not-applicant".to_string());
}
if requirements.statement_type != VETTED_PREDICATE {
failures.push("wrong-statement-type".to_string());
}
let vetter = vetted.vetter_members();
if vetter.is_none() {
failures.push("not-a-vetter-statement".to_string());
}
let predicate_accepted =
vta_sdk::vetting::dtg_shape(vc).is_ok_and(|parsed| accepted.accept(&parsed).is_ok());
if !predicate_accepted {
failures.push("predicate-not-accepted".to_string());
}
let eligible = vetter_eligible(
state,
verified.issuer(),
&requirements.eligible_vetters.role,
verified.valid_from(),
)
.await?;
let revoked = revocation::is_revoked(
&state.vetting_revocations_ks,
verified.issuer(),
verified.id(),
verified.digest_multibase(),
)
.await?;
let fact = VettingStatementFact {
id: Some(verified.id().to_string()),
issuer: Some(verified.issuer().to_string()),
verified: true,
eligible,
revoked,
method: Some(vetted.method.to_string()),
declared_relationship: vetted.declared_relationship.map(|r| r.to_string()),
counted: false,
failures,
};
if let (true, Some(vetter)) = (fact.failures.is_empty(), vetter) {
to_count.push(StatementFacts {
statement_id: verified.id().to_string(),
vetter: verified.issuer().to_string(),
method: vetted.method,
claims_verified: vetted
.claims_verified
.iter()
.map(|c| c.as_str().to_owned())
.collect(),
document_classes: vetted
.document_classes
.iter()
.map(|d| d.as_str().to_owned())
.collect(),
declared_relationship: vetter.declared_relationship,
identity_commitment: vetter.identity_commitment.to_owned(),
valid_from: verified.valid_from(),
community_matches: vetted.community == community_did,
eligible,
revoked,
});
}
statements.push(fact);
}
let evaluation = evaluate(requirements, &to_count, now);
for fact in &mut statements {
let Some(id) = fact.id.as_deref() else {
continue;
};
if evaluation.counted.iter().any(|c| c == id) {
fact.counted = true;
} else if let Some((_, reasons)) = evaluation.not_counted.iter().find(|(nc, _)| nc == id) {
fact.failures
.extend(reasons.iter().map(|r| r.code().to_string()));
}
}
let needs: Vec<String> = evaluation.needs.iter().map(|n| n.to_wire()).collect();
Ok(Some(VettingFacts {
criterion_id: selected.criterion_id,
requirements_digest: selected.digest,
applicant_digest_matches: selected.applicant_digest_matches,
statements,
distinct_counted_vetters: u32::try_from(evaluation.distinct_vetters()).unwrap_or(u32::MAX),
by_method: evaluation
.by_method
.iter()
.map(|(m, n)| (m.to_string(), *n))
.collect(),
commitments_consistent: evaluation.commitments_consistent,
independence_ok: evaluation.independence_ok,
invitation_required: matches!(
requirements.invitation,
Some(VettingRequirementsInvitation::Required)
),
satisfied: evaluation.satisfied(),
needs,
}))
}
fn hidden_without_proof(
applicant_did: &str,
selected: Selected,
vp: &JsonValue,
now: DateTime<Utc>,
) -> VettingFacts {
let reason = if cfg!(feature = "vetting-pcs") {
HIDDEN_NO_PROOF
} else {
HIDDEN_UNSUPPORTED
};
let statements: Vec<VettingStatementFact> =
vetting_credentials(vp, &selected.requirements.statement_type)
.map(|vc| VettingStatementFact {
id: vc.get("id").and_then(JsonValue::as_str).map(str::to_string),
issuer: issuer_of(vc),
verified: false,
eligible: false,
revoked: false,
method: None,
declared_relationship: None,
counted: false,
failures: vec![NAMED_ON_HIDDEN.to_string()],
})
.collect();
tracing::info!(
applicant = %applicant_did,
criterion = %selected.criterion_id,
digest = %selected.digest,
reason,
named_statements_refused = statements.len(),
"criterion counts hidden vetting only, and no hidden-vetting proof was counted for this \
submission"
);
let evaluation = evaluate(&selected.requirements, &[], now);
VettingFacts {
criterion_id: selected.criterion_id,
requirements_digest: selected.digest,
applicant_digest_matches: selected.applicant_digest_matches,
statements,
distinct_counted_vetters: 0,
by_method: BTreeMap::new(),
commitments_consistent: evaluation.commitments_consistent,
independence_ok: evaluation.independence_ok,
invitation_required: matches!(
selected.requirements.invitation,
Some(VettingRequirementsInvitation::Required)
),
satisfied: false,
needs: vec![format!("{NEED_HIDDEN_PREFIX}{reason}")],
}
}
#[cfg_attr(not(feature = "vetting-pcs"), allow(dead_code))]
fn hidden_reasons(statements: &[VettingStatementFact]) -> Vec<String> {
let mut reasons: Vec<String> = Vec::new();
for fact in statements.iter().filter(|f| !f.counted) {
for failure in &fact.failures {
if !reasons.contains(failure) {
reasons.push(failure.clone());
}
}
}
if reasons.iter().any(|r| r != "issuer-not-vetter") {
reasons.retain(|r| r != "issuer-not-vetter");
}
reasons
}
#[cfg(feature = "vetting-pcs")]
async fn hidden_facts(
state: &AppState,
community_did: &str,
applicant_did: &str,
selected: &Selected,
requirements: &VettingRequirements,
extensions: &JsonValue,
now: DateTime<Utc>,
) -> Result<Option<VettingFacts>, FactsError> {
let Some(raw) = selected.hidden_vetting.clone() else {
return Ok(None);
};
let config: crate::vetting::pcs::HiddenVettingConfig = serde_json::from_value(raw)
.map_err(|e| AppError::Validation(format!("hidden-vetting parameters: {e}")))?;
let decision = crate::vetting::pcs::decide(
state,
community_did,
applicant_did,
requirements,
&selected.digest,
&config,
extensions,
now,
)
.await
.map_err(|e| match e {
crate::vetting::pcs::DecideError::Challenge(r) => FactsError::Challenge(r),
crate::vetting::pcs::DecideError::Store(e) => FactsError::App(e),
crate::vetting::pcs::DecideError::Proof(e) => {
FactsError::App(AppError::Validation(format!("hidden vetting: {e}")))
}
})?;
let Some(decision) = decision else {
return Ok(None);
};
let evaluation = &decision.evaluation;
let mut facts = VettingFacts {
criterion_id: selected.criterion_id.clone(),
requirements_digest: selected.digest.clone(),
applicant_digest_matches: selected.applicant_digest_matches,
statements: decision
.statements
.iter()
.map(|s| VettingStatementFact {
id: Some(s.id.clone()),
issuer: Some(s.issuer.clone()),
verified: s.verified,
eligible: s.eligible,
revoked: s.revoked,
method: Some(s.method.to_string()),
declared_relationship: Some(s.declared_relationship.to_string()),
counted: s.counted,
failures: s.failures.clone(),
})
.collect(),
distinct_counted_vetters: u32::try_from(evaluation.distinct_vetters()).unwrap_or(u32::MAX),
by_method: evaluation
.by_method
.iter()
.map(|(m, n)| (m.to_string(), *n))
.collect(),
commitments_consistent: evaluation.commitments_consistent,
independence_ok: evaluation.independence_ok,
invitation_required: matches!(
requirements.invitation,
Some(VettingRequirementsInvitation::Required)
),
satisfied: evaluation.satisfied(),
needs: evaluation.needs.iter().map(|n| n.to_wire()).collect(),
};
let reasons = hidden_reasons(&facts.statements);
tracing::info!(
applicant = %applicant_did,
criterion = %facts.criterion_id,
digest = %facts.requirements_digest,
attestations = facts.statements.len(),
counted = facts.statements.iter().filter(|s| s.counted).count(),
distinct_vetters = facts.distinct_counted_vetters,
satisfied = facts.satisfied,
needs = ?facts.needs,
not_counted_because = ?reasons,
"hidden-vetting proof verified and counted"
);
if !facts.satisfied {
facts
.needs
.extend(reasons.iter().map(|r| format!("{NEED_HIDDEN_PREFIX}{r}")));
}
Ok(Some(facts))
}
pub fn expand_needs(needs: &mut Vec<String>, facts: Option<&VettingFacts>) {
let Some(facts) = facts else {
return;
};
if facts.needs.is_empty() {
return;
}
if let Some(pos) = needs.iter().position(|n| n == NEED_VETTING) {
needs.splice(pos..=pos, facts.needs.iter().cloned());
}
}
#[derive(Debug, Clone)]
struct Selected {
criterion_id: String,
requirements: VettingRequirements,
digest: String,
applicant_digest_matches: bool,
hidden_vetting: Option<JsonValue>,
}
fn vetting_credentials<'a>(
vp: &'a JsonValue,
statement_type: &'a str,
) -> impl Iterator<Item = &'a JsonValue> {
vp.get("verifiableCredential")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter(move |vc| {
let predicate = vc
.pointer("/credentialSubject/predicate")
.and_then(JsonValue::as_str);
predicate == Some(statement_type) || predicate == Some(VETTED_PREDICATE)
})
}
fn issuer_of(vc: &JsonValue) -> Option<String> {
match vc.get("issuer")? {
JsonValue::String(s) => Some(s.clone()),
JsonValue::Object(o) => o.get("id")?.as_str().map(str::to_string),
_ => None,
}
}
pub(crate) async fn vetter_eligible(
state: &AppState,
issuer: &str,
role: &str,
issued_at: DateTime<Utc>,
) -> Result<bool, AppError> {
let Some(member) = get_member(&state.members_ks, issuer).await? else {
return Ok(false);
};
if member.removed_at.is_some() || vetters::joined_at_second(&member) > issued_at {
return Ok(false);
}
let grants =
endorsements_for_subject(&state.endorsements_ks, issuer, VETTER_GRANT_ROW_TYPE).await?;
Ok(grants.iter().any(|g| {
vetters::recorded_during_membership(g, &member) && vetters::grant_covers(g, role, issued_at)
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_decided_hidden_proof_is_redacted_to_a_digest() {
let mut extensions = json!({
"requirementsDigest": "zQmDigest",
HIDDEN_VETTING_MEMBER: {
"suite": "ps-ddh-bls12381",
"id": "z7Applicant",
"proof": "zTheWholeProofWithTagsInside",
"statements": [{ "meta": {}, "token": {} }],
},
});
assert!(redact_hidden_submission(&mut extensions));
let left = &extensions[HIDDEN_VETTING_MEMBER];
assert_eq!(left["redacted"], json!(true));
assert_eq!(left["suite"], json!("ps-ddh-bls12381"));
assert!(left["bytes"].as_u64().unwrap() > 0);
assert_eq!(left["sha256"].as_str().unwrap().len(), 64);
let text = serde_json::to_string(&extensions).unwrap();
assert!(!text.contains("zTheWholeProofWithTagsInside"), "{text}");
assert!(!text.contains("z7Applicant"), "{text}");
assert_eq!(extensions["requirementsDigest"], json!("zQmDigest"));
}
#[test]
fn redaction_leaves_a_named_submission_alone() {
let before = json!({ "requirementsDigest": "zQmDigest" });
let mut after = before.clone();
assert!(!redact_hidden_submission(&mut after));
assert_eq!(before, after);
let mut odd = json!("not an object");
assert!(!redact_hidden_submission(&mut odd));
}
#[cfg(feature = "vetting-pcs")]
fn hidden_fact(counted: bool, failures: &[&str]) -> VettingStatementFact {
VettingStatementFact {
id: Some("s".into()),
issuer: Some("zMasked".into()),
verified: true,
eligible: failures.is_empty(),
revoked: false,
method: None,
declared_relationship: None,
counted,
failures: failures.iter().map(|f| (*f).to_string()).collect(),
}
}
#[cfg(feature = "vetting-pcs")]
#[test]
fn hidden_reasons_name_the_specific_cause() {
let reasons = hidden_reasons(&[
hidden_fact(
false,
&["requirements-digest-mismatch", "issuer-not-vetter"],
),
hidden_fact(false, &["token-label-not-live", "issuer-not-vetter"]),
hidden_fact(false, &["requirements-digest-mismatch"]),
hidden_fact(true, &[]),
]);
assert_eq!(
reasons,
["requirements-digest-mismatch", "token-label-not-live"]
);
assert_eq!(
hidden_reasons(&[hidden_fact(false, &["issuer-not-vetter"])]),
["issuer-not-vetter"]
);
assert!(hidden_reasons(&[hidden_fact(true, &[])]).is_empty());
}
#[test]
fn a_hidden_need_is_not_a_count() {
for code in [
HIDDEN_NO_PROOF,
HIDDEN_UNSUPPORTED,
"requirements-digest-mismatch",
] {
let need = format!("{NEED_HIDDEN_PREFIX}{code}");
assert!(vta_sdk::vetting::requirements::Need::parse(&need).is_none());
}
}
fn facts_with_needs(needs: &[&str]) -> VettingFacts {
VettingFacts {
criterion_id: "a".into(),
requirements_digest: "za".into(),
applicant_digest_matches: true,
statements: vec![],
distinct_counted_vetters: 1,
by_method: BTreeMap::new(),
commitments_consistent: true,
independence_ok: true,
invitation_required: false,
satisfied: false,
needs: needs.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn a_generic_vetting_need_becomes_the_precise_shortfall() {
let mut needs = vec!["agreed:code-of-conduct".into(), NEED_VETTING.into()];
let f = facts_with_needs(&["vetting:statements:1", "vetting:method:inPerson:1"]);
expand_needs(&mut needs, Some(&f));
assert_eq!(
needs,
vec![
"agreed:code-of-conduct",
"vetting:statements:1",
"vetting:method:inPerson:1"
]
);
}
#[test]
fn needs_are_left_alone_without_facts_or_a_specific_shortfall() {
let mut needs = vec![NEED_VETTING.to_string()];
expand_needs(&mut needs, None);
assert_eq!(needs, vec![NEED_VETTING]);
expand_needs(&mut needs, Some(&facts_with_needs(&[])));
assert_eq!(needs, vec![NEED_VETTING]);
}
#[test]
fn only_vetting_statements_are_picked_out_of_a_presentation() {
let vp = json!({
"verifiableCredential": [
{ "credentialSubject": { "predicate": VETTED_PREDICATE } },
{ "credentialSubject": { "predicate": dtg_credentials::ENDORSES_V1 } },
{ "credentialSubject": { "predicate": "dtg:vetted" } },
{ "type": ["VerifiableCredential", "InvitationCredential"] },
"eyJhbGciOi.jwt.vc"
]
});
assert_eq!(vetting_credentials(&vp, VETTED_PREDICATE).count(), 1);
}
}