use std::sync::Arc;
use affinidi_openid4vp::DcqlQuery;
use axum::Json;
use axum::extract::State;
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::auth::AdminAuth;
use vti_common::error::AppError;
use crate::ceremony::{Credential, CredentialStatus, Presentation};
use crate::credentials::present_challenge::{self, DEFAULT_CHALLENGE_TTL};
use crate::credentials::{VerifiedPresentation, VerifiedPresentationSet, verify_vp_token};
use crate::join::JoinTransport;
use crate::recognition::{
DidResolverKeyResolver, ForeignIssuerKeyResolver, HttpStatusListFetcher, StatusListFetcher,
};
use crate::registry::TrustRegistryClient;
use crate::schemas::accepts::get_accepts;
use crate::server::AppState;
use super::submit::{JoinSubmitOutcome, decide_join, realize_join_verdict};
pub async fn present_and_decide_join(
state: &AppState,
vp_token: &JsonValue,
expected_aud: &str,
expected_nonce: &str,
transport: JoinTransport,
now: DateTime<Utc>,
) -> Result<JoinSubmitOutcome, AppError> {
let set = verify_vp_token(
vp_token,
expected_aud,
expected_nonce,
state.did_resolver.as_ref(),
now,
)
.await?;
let applicant_did = set.holder.clone();
let presentation = presentation_from_verified_set(state, &set).await;
let verdict = decide_join(state, &applicant_did, presentation).await?;
let vp_claims = vp_claims_from_set(&set);
realize_join_verdict(
state,
&applicant_did,
vp_token.clone(),
vp_claims,
false,
JsonValue::Null,
verdict,
transport,
)
.await
}
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::from_json(&criterion.query).map_err(|e| {
AppError::Internal(format!(
"registered Accepts criterion `{criterion_id}` is not a valid DCQL query: {e}"
))
})?;
let nonce = present_challenge::issue(
&state.join_requests_ks,
thread_id,
&vtc_did,
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,
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendQueryRequest {
pub holder_did: String,
pub criterion_id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SendQueryResponse {
pub thread_id: String,
pub holder_did: String,
pub query: QueryBody,
pub delivered: bool,
}
pub async fn send_query(
_auth: AdminAuth,
State(state): State<AppState>,
Json(body): Json<SendQueryRequest>,
) -> Result<Json<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, "pushed credential query over DIDComm");
true
}
Err(e) => {
warn!(holder = %body.holder_did, thread = %thread_id, error = %e, "credential-query push failed — returning query for relay");
false
}
};
Ok(Json(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_to_holder(
state,
holder_did,
thread_id,
CREDENTIAL_QUERY_TYPE,
body,
)
.await
}
async fn presentation_from_verified_set(
state: &AppState,
set: &VerifiedPresentationSet,
) -> Presentation {
let own_did = state.config.read().await.vtc_did.clone();
let registry = state.registry_client.as_deref();
let status_fetcher = match state.did_resolver.clone() {
Some(resolver) => {
let key_resolver: Arc<dyn ForeignIssuerKeyResolver> =
Arc::new(DidResolverKeyResolver::new(resolver));
HttpStatusListFetcher::with_issuer_verification(reqwest::Client::new(), key_resolver)
}
None => HttpStatusListFetcher::new(reqwest::Client::new()),
};
let mut credentials = Vec::with_capacity(set.presentations.len());
for p in &set.presentations {
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;
credentials.push(credential_from_verified(p, trusted, status));
}
Presentation {
verified: true,
holder: set.holder.clone(),
credentials,
}
}
fn credential_from_verified(
p: &VerifiedPresentation,
issuer_trusted: bool,
status: CredentialStatus,
) -> Credential {
Credential {
credential_type: p
.vct
.clone()
.unwrap_or_else(|| "VerifiableCredential".to_string()),
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)),
}
}
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;
};
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
}
}
}
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));
}
if let Some(sl) = entry.get("status_list") {
let url = sl.get("uri").and_then(JsonValue::as_str)?;
let index = parse_status_index(sl.get("idx"))?;
return Some((url.to_string(), index, false));
}
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,
}
}
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,
}
}
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()))
}
}
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);
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);
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 sd_jwt_status_list_shape_resolves() {
let entry = json!({ "status_list": { "idx": 7, "uri": "https://issuer.example/sl" } });
let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(true))).await;
assert_eq!(status, CredentialStatus::Revoked);
}
#[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);
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);
}
}