use std::sync::Arc;
use affinidi_openid4vp::DcqlQuery;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use tracing::{info, warn};
use uuid::Uuid;
use vta_sdk::protocols::credential_exchange::{QUERY as CREDENTIAL_QUERY_TYPE, QueryBody};
use vti_common::error::AppError;
use crate::ceremony::{Credential, CredentialStatus, Presentation};
use crate::credentials::present_challenge::{self, DEFAULT_CHALLENGE_TTL};
use crate::credentials::task_context::{self, TaskContextBinding};
use crate::credentials::witness::{self, WitnessBinding};
use crate::credentials::{VerifiedPresentation, VerifiedPresentationSet};
use vti_common::auth::PurposeVmResolver;
use crate::credentials::vm_resolver::DidVmResolver;
use crate::join::JoinTransport;
use crate::recognition::{HttpStatusListFetcher, StatusListFetcher};
use crate::registry::TrustRegistryClient;
use crate::schemas::accepts::get_accepts;
use crate::server::AppState;
use crate::join::{JoinSubmitOutcome, decide_join, realize_join_verdict};
#[allow(clippy::too_many_arguments)]
pub async fn present_and_decide_join(
state: &AppState,
vp_token: &JsonValue,
expected_aud: &str,
expected_nonce: &str,
thread_id: &str,
criterion: Option<&str>,
transport: JoinTransport,
now: DateTime<Utc>,
) -> Result<JoinSubmitOutcome, AppError> {
let set = crate::credentials::verify_vp_token_with_anchors(
vp_token,
expected_aud,
expected_nonce,
state.did_resolver.as_ref(),
&state.x509_trust_anchors,
now,
)
.await?;
let applicant_did = set.holder.clone();
let presentation = presentation_from_verified_set(state, &set, thread_id).await?;
let presented = crate::join::criteria::Presented {
credentials: crate::join::criteria::presented_from_verified(
&presentation,
&set.presentations,
),
..Default::default()
};
let governing =
crate::join::criteria::govern_by_id(state, &applicant_did, &presented, criterion, now)
.await
.map_err(|r| {
match r {
crate::join::criteria::CriterionRefusal::NotAccepting => AppError::Validation(
"this community publishes no join criteria, so it is not accepting applications"
.into(),
),
crate::join::criteria::CriterionRefusal::Unknown(id) => AppError::Validation(format!(
"the join criterion `{id}` this presentation answers is no longer published"
)),
crate::join::criteria::CriterionRefusal::Challenge(r) => r.into(),
crate::join::criteria::CriterionRefusal::Other(e) => e,
}
})?;
let verdict = decide_join(
state,
&applicant_did,
presentation,
None,
&governing,
Some(thread_id),
)
.await?;
let vp_claims = vp_claims_from_set(&set);
let outcome = realize_join_verdict(
state,
&applicant_did,
vp_token.clone(),
vp_claims,
false,
JsonValue::Null,
Vec::new(),
verdict,
transport,
None,
)
.await?;
crate::join::orchestrate::record_criterion(state, outcome.request.id, &governing).await;
Ok(outcome)
}
pub async fn prepare_join_query(
state: &AppState,
thread_id: &str,
criterion_id: &str,
now: DateTime<Utc>,
) -> Result<QueryBody, AppError> {
let vtc_did = state.config.read().await.vtc_did.clone().ok_or_else(|| {
AppError::Internal("VTC DID not configured — cannot bind a presentation challenge".into())
})?;
let criterion = get_accepts(&state.schemas_ks, criterion_id)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("no Accepts criterion `{criterion_id}` registered"))
})?;
let dcql_query: DcqlQuery = criterion.dcql()?.ok_or_else(|| {
AppError::Validation(format!(
"the Accepts criterion `{criterion_id}` asks for no credential, so there is no query \
to send"
))
})?;
let nonce = present_challenge::issue(
&state.join_requests_ks,
thread_id,
&vtc_did,
Some(criterion_id),
DEFAULT_CHALLENGE_TTL,
now,
)
.await?;
let purpose = criterion
.description
.unwrap_or_else(|| format!("join: present credentials satisfying `{criterion_id}`"));
Ok(QueryBody {
dcql_query,
nonce,
purpose,
oid4vp_session: None,
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct SendQueryRequest {
pub holder_did: String,
pub criterion_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct SendQueryResponse {
pub thread_id: String,
pub holder_did: String,
#[schema(value_type = Object)]
pub query: QueryBody,
pub delivered: bool,
}
pub(crate) async fn send_query_inner(
state: &AppState,
body: SendQueryRequest,
) -> Result<SendQueryResponse, AppError> {
let thread_id = Uuid::new_v4().to_string();
let query = prepare_join_query(state, &thread_id, &body.criterion_id, Utc::now()).await?;
let delivered = match push_credential_query(state, &body.holder_did, &thread_id, &query).await {
Ok(()) => {
info!(holder = %body.holder_did, thread = %thread_id, "queued credential query for guaranteed delivery");
true
}
Err(e) => {
warn!(holder = %body.holder_did, thread = %thread_id, error = %e, "credential-query push failed — returning query for relay");
false
}
};
Ok(SendQueryResponse {
thread_id,
holder_did: body.holder_did,
query,
delivered,
})
}
async fn push_credential_query(
state: &AppState,
holder_did: &str,
thread_id: &str,
query: &QueryBody,
) -> Result<(), AppError> {
let body = serde_json::to_value(query)
.map_err(|e| AppError::Internal(format!("query serialise: {e}")))?;
crate::credentials::delivery::push_document(
state,
holder_did,
CREDENTIAL_QUERY_TYPE,
body,
crate::credentials::delivery::Thread::Root(thread_id),
)
.await
.map(|_| ())
}
async fn presentation_from_verified_set(
state: &AppState,
set: &VerifiedPresentationSet,
exchange_thread: &str,
) -> Result<Presentation, AppError> {
let own_did = state.config.read().await.vtc_did.clone();
let registry = state.registry_client.as_deref();
let status_fetcher = credential_status_fetcher(state);
let accepted = crate::endorsement_types::accept_list(&state.endorsement_types_ks).await?;
let trusted_witnesses = crate::credentials::trusted_witnesses::live_for(state).await;
let vm_resolver = state.trust_task_vm_resolver();
let witness_ctx = witness::WitnessContext {
relationships_ks: &state.relationships_ks,
trusted: &trusted_witnesses,
evidence: &[],
resolver: &vm_resolver,
};
let mut credentials = Vec::with_capacity(set.presentations.len());
for p in &set.presentations {
if let Some(predicate) = crate::credentials::ingress::statement_predicate(&p.claims)
&& !accepted.contains(&predicate)
{
return Err(AppError::Validation(format!(
"presented statement from {} is under predicate `{predicate}`, which this \
community does not accept",
p.issuer_did
)));
}
let trusted = issuer_trusted(registry, own_did.as_deref(), &p.issuer_did).await;
let status = resolve_presented_status(
p.credential_status.as_ref(),
Some(&p.issuer_did),
&status_fetcher,
)
.await;
let is_witness = witness::is_witness_statement(&p.claims);
let witness_binding = if is_witness {
Some(witness::assess_or_unresolved(&witness_ctx, &p.issuer_did, &p.claims).await)
} else {
None
};
let requirement = task_context::requirement_for_predicate(
crate::credentials::ingress::statement_predicate(&p.claims).as_deref(),
);
let task_context = task_context::resolve(
requirement,
p.task_context.as_deref(),
Some(exchange_thread),
)?;
credentials.push(credential_from_verified(
p,
trusted,
status,
witness_binding,
Some(task_context),
));
}
Ok(Presentation {
verified: true,
holder: set.holder.clone(),
credentials,
})
}
fn credential_from_verified(
p: &VerifiedPresentation,
issuer_trusted: bool,
status: CredentialStatus,
witness_binding: Option<WitnessBinding>,
task_context: Option<TaskContextBinding>,
) -> Credential {
Credential {
credential_type: p
.vct
.clone()
.unwrap_or_else(|| "VerifiableCredential".to_string()),
predicate: crate::credentials::ingress::statement_predicate(&p.claims),
issuer: p.issuer_did.clone(),
issuer_trusted,
status,
holder_bound: p.holder_bound,
claims: p.claims.clone(),
valid_until: p
.claims
.get("exp")
.and_then(JsonValue::as_i64)
.and_then(|s| DateTime::from_timestamp(s, 0)),
witness_binding,
task_context,
}
}
pub(crate) fn credential_status_fetcher(state: &AppState) -> HttpStatusListFetcher {
let fetcher = match state.did_resolver.clone() {
Some(resolver) => {
let key_resolver: Arc<dyn PurposeVmResolver> =
Arc::new(DidVmResolver::new(Some(resolver)));
HttpStatusListFetcher::with_issuer_verification(key_resolver)
}
None => HttpStatusListFetcher::new(),
};
fetcher.with_x509_anchors(state.x509_trust_anchors.clone())
}
pub(crate) async fn resolve_presented_status(
status_entry: Option<&JsonValue>,
expected_issuer: Option<&str>,
fetcher: &dyn StatusListFetcher,
) -> CredentialStatus {
let Some(entry) = status_entry else {
return CredentialStatus::Valid;
};
if entry.get("status_list").is_some() {
return resolve_token_status(entry, expected_issuer, fetcher).await;
}
let (url, index, suspension) = match parse_status_entry(entry) {
Some(parts) => parts,
None => return CredentialStatus::Unknown,
};
match fetcher.check_status_bit(&url, index, expected_issuer).await {
Ok(false) => CredentialStatus::Valid,
Ok(true) if suspension => CredentialStatus::Suspended,
Ok(true) => CredentialStatus::Revoked,
Err(e) => {
warn!(
url = %url,
error = %e,
"presented credential's status list did not resolve — surfacing Unknown for the join policy"
);
CredentialStatus::Unknown
}
}
}
async fn resolve_token_status(
status: &JsonValue,
expected_issuer: Option<&str>,
fetcher: &dyn StatusListFetcher,
) -> CredentialStatus {
use affinidi_status_list::token::{StatusListReference, TokenStatus};
let reference = match StatusListReference::from_claims(&json!({ "status": status })) {
Ok(Some(reference)) => reference,
_ => return CredentialStatus::Unknown,
};
match fetcher
.check_token_status(&reference, expected_issuer)
.await
{
Ok(TokenStatus::Valid) => CredentialStatus::Valid,
Ok(TokenStatus::Invalid) => CredentialStatus::Revoked,
Ok(TokenStatus::Suspended) => CredentialStatus::Suspended,
Ok(other) => {
warn!(
uri = %reference.uri,
value = other.value(),
"presented credential's Token Status List entry has no registered meaning — surfacing Unknown"
);
CredentialStatus::Unknown
}
Err(e) => {
warn!(
uri = %reference.uri,
error = %e,
"presented credential's Token Status List did not resolve — surfacing Unknown for the join policy"
);
CredentialStatus::Unknown
}
}
}
fn parse_status_entry(entry: &JsonValue) -> Option<(String, usize, bool)> {
if let Some(url) = entry
.get("statusListCredential")
.and_then(JsonValue::as_str)
{
let index = parse_status_index(entry.get("statusListIndex"))?;
let suspension =
entry.get("statusPurpose").and_then(JsonValue::as_str) == Some("suspension");
return Some((url.to_string(), index, suspension));
}
None
}
fn parse_status_index(v: Option<&JsonValue>) -> Option<usize> {
match v {
Some(JsonValue::String(s)) => s.parse::<usize>().ok(),
Some(JsonValue::Number(n)) => n.as_u64().map(|u| u as usize),
_ => None,
}
}
pub(crate) async fn issuer_trusted(
registry: Option<&dyn TrustRegistryClient>,
own_did: Option<&str>,
issuer_did: &str,
) -> bool {
if own_did == Some(issuer_did) {
return true;
}
let Some(registry) = registry else {
return false;
};
match registry.recognise(issuer_did).await {
Ok(trusted) => trusted,
Err(e) => {
warn!(
issuer = %issuer_did,
error = %e,
"trust-registry recognise failed — treating issuer as untrusted; join policy decides"
);
false
}
}
}
fn vp_claims_from_set(set: &VerifiedPresentationSet) -> JsonValue {
let credentials: Vec<JsonValue> = set
.presentations
.iter()
.map(|p| {
json!({
"issuer": p.issuer_did,
"type": p.vct,
"credentialSubject": p.claims,
})
})
.collect();
json!({ "holder": set.holder, "credentials": credentials })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recognition::RecognitionError;
use crate::registry::MockRegistryClient;
fn sample_presentation() -> VerifiedPresentation {
VerifiedPresentation {
issuer_did: "did:key:zIssuer".into(),
holder_did: "did:key:zHolder".into(),
vct: Some("https://openvtc.org/credentials/MembershipCredential".into()),
holder_bound: true,
claims: json!({ "givenName": "Alice", "exp": 1_900_000_000 }),
credential_status: None,
task_context: None,
issuer_authority_key_ids: Vec::new(),
}
}
struct StubFetcher(Result<bool, ()>);
#[async_trait::async_trait]
impl StatusListFetcher for StubFetcher {
async fn check_status_bit(
&self,
_url: &str,
_index: usize,
_expected_issuer: Option<&str>,
) -> Result<bool, RecognitionError> {
self.0
.map_err(|()| RecognitionError::StatusListFailed("stub".into()))
}
}
struct TokenStub(Result<affinidi_status_list::token::TokenStatus, ()>);
#[async_trait::async_trait]
impl StatusListFetcher for TokenStub {
async fn check_status_bit(
&self,
_url: &str,
_index: usize,
_expected_issuer: Option<&str>,
) -> Result<bool, RecognitionError> {
panic!("a Token Status List entry must not be read as a Bitstring bit")
}
async fn check_token_status(
&self,
reference: &affinidi_status_list::token::StatusListReference,
expected_issuer: Option<&str>,
) -> Result<affinidi_status_list::token::TokenStatus, RecognitionError> {
assert_eq!(reference.idx, 7);
assert_eq!(reference.uri, "https://issuer.example/sl");
assert_eq!(expected_issuer, Some("did:web:issuer.example"));
self.0
.map_err(|()| RecognitionError::StatusListFailed("stub".into()))
}
}
fn w3c_status(purpose: &str) -> JsonValue {
json!({
"type": "BitstringStatusListEntry",
"statusPurpose": purpose,
"statusListIndex": "42",
"statusListCredential": "https://issuer.example/status/1",
})
}
#[test]
fn projects_a_verified_presentation_into_a_ceremony_credential() {
let c = credential_from_verified(
&sample_presentation(),
true,
CredentialStatus::Valid,
None,
None,
);
assert_eq!(
c.credential_type,
"https://openvtc.org/credentials/MembershipCredential"
);
assert_eq!(c.issuer, "did:key:zIssuer");
assert!(c.issuer_trusted);
assert_eq!(c.status, CredentialStatus::Valid);
assert!(c.valid_until.is_some());
assert_eq!(c.claims["givenName"], "Alice");
}
#[test]
fn resolved_status_carries_through_to_the_credential() {
let c = credential_from_verified(
&sample_presentation(),
true,
CredentialStatus::Revoked,
None,
None,
);
assert_eq!(c.status, CredentialStatus::Revoked);
}
#[tokio::test]
async fn no_status_block_is_valid_without_a_fetch() {
let status = resolve_presented_status(None, None, &StubFetcher(Ok(true))).await;
assert_eq!(status, CredentialStatus::Valid);
}
#[tokio::test]
async fn clear_bit_is_valid() {
let entry = w3c_status("revocation");
let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(false))).await;
assert_eq!(status, CredentialStatus::Valid);
}
#[tokio::test]
async fn set_revocation_bit_is_revoked() {
let entry = w3c_status("revocation");
let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(true))).await;
assert_eq!(status, CredentialStatus::Revoked);
}
#[tokio::test]
async fn set_suspension_bit_is_suspended() {
let entry = w3c_status("suspension");
let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(true))).await;
assert_eq!(status, CredentialStatus::Suspended);
}
#[tokio::test]
async fn token_status_list_entries_resolve_by_status_value() {
use affinidi_status_list::token::TokenStatus;
let entry = json!({ "status_list": { "idx": 7, "uri": "https://issuer.example/sl" } });
let issuer = Some("did:web:issuer.example");
for (answer, expected) in [
(Ok(TokenStatus::Valid), CredentialStatus::Valid),
(Ok(TokenStatus::Invalid), CredentialStatus::Revoked),
(Ok(TokenStatus::Suspended), CredentialStatus::Suspended),
(Ok(TokenStatus::from_value(0x03)), CredentialStatus::Unknown),
(Ok(TokenStatus::from_value(0x07)), CredentialStatus::Unknown),
(Err(()), CredentialStatus::Unknown),
] {
let status = resolve_presented_status(Some(&entry), issuer, &TokenStub(answer)).await;
assert_eq!(status, expected, "{answer:?}");
}
}
#[tokio::test]
async fn a_token_status_list_without_support_is_unknown() {
let entry = json!({ "status_list": { "idx": 7, "uri": "https://issuer.example/sl" } });
let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(false))).await;
assert_eq!(status, CredentialStatus::Unknown);
}
#[tokio::test]
async fn a_malformed_token_status_reference_is_unknown() {
for entry in [
json!({ "status_list": { "idx": "7", "uri": "https://issuer.example/sl" } }),
json!({ "status_list": { "idx": 7 } }),
] {
let status = resolve_presented_status(
Some(&entry),
Some("did:web:issuer.example"),
&TokenStub(Ok(affinidi_status_list::token::TokenStatus::Valid)),
)
.await;
assert_eq!(status, CredentialStatus::Unknown, "{entry}");
}
}
#[tokio::test]
async fn unreachable_status_list_is_unknown_not_valid() {
let entry = w3c_status("revocation");
let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Err(()))).await;
assert_eq!(status, CredentialStatus::Unknown);
}
#[tokio::test]
async fn malformed_status_entry_is_unknown() {
let entry = json!({ "type": "BitstringStatusListEntry" }); let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(false))).await;
assert_eq!(status, CredentialStatus::Unknown);
}
#[test]
fn untrusted_issuer_carries_through_to_the_credential() {
let c = credential_from_verified(
&sample_presentation(),
false,
CredentialStatus::Valid,
None,
None,
);
assert!(!c.issuer_trusted);
}
#[tokio::test]
async fn own_did_is_trusted_without_consulting_the_registry() {
let registry = MockRegistryClient::new();
assert!(issuer_trusted(Some(®istry), Some("did:vtc:home"), "did:vtc:home").await);
assert_eq!(registry.call_counts().await.recognise, 0);
}
#[tokio::test]
async fn recognised_foreign_issuer_is_trusted() {
let registry = MockRegistryClient::new();
registry.set_recognised("did:webvh:peer.example:abc").await;
assert!(
issuer_trusted(
Some(®istry),
Some("did:vtc:home"),
"did:webvh:peer.example:abc"
)
.await
);
assert_eq!(registry.call_counts().await.recognise, 1);
}
#[tokio::test]
async fn unrecognised_foreign_issuer_is_not_trusted() {
let registry = MockRegistryClient::new();
assert!(
!issuer_trusted(
Some(®istry),
Some("did:vtc:home"),
"did:webvh:stranger.example"
)
.await
);
}
#[tokio::test]
async fn registry_error_fails_soft_to_untrusted() {
let registry = MockRegistryClient::new();
registry
.fail_next_recognise(crate::registry::RegistryError::Unreachable("dns".into()))
.await;
assert!(
!issuer_trusted(
Some(®istry),
Some("did:vtc:home"),
"did:webvh:peer.example"
)
.await
);
}
#[tokio::test]
async fn no_registry_mode_is_not_trusted() {
assert!(!issuer_trusted(None, Some("did:vtc:home"), "did:webvh:peer.example").await);
}
}