use std::collections::BTreeMap;
use affinidi_oid4vc_core::jwe::{ContentEncryption, EcdhEsDecryptionKey};
use affinidi_openid4vp::dcql::CandidateCredential;
use affinidi_openid4vp::request_object::{
REQUEST_OBJECT_TYP, RequestObject, VerifierClientMetadata,
};
use affinidi_openid4vp::response::{DirectPostJwt, receive_direct_post_jwt};
use affinidi_openid4vp::{ClientId, DcqlQuery};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Duration, Utc};
use serde_json::{Map, Value, json};
use tracing::{info, warn};
use uuid::Uuid;
use vti_common::error::AppError;
use super::session::{
self, EvidenceCredential, RefusalReason, SessionPhase, WalletEvidence, WalletSession,
};
use super::{VerifierIdentityRecord, VerifierKind, list_identities};
use crate::ceremony::CredentialStatus;
use crate::join::JoinStatus;
use crate::join::criteria::{LDP_VC_FORMAT, SD_JWT_VC_FORMAT};
use crate::server::AppState;
pub const SWIYU_PROFILE_VERSION: &str = "swiss-profile-verification:1.0.0";
static JOIN_HANDOFF: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
const RESPONSE_KEY_ID: &str = "response-encryption";
#[derive(Debug)]
pub enum StartRefusal {
CriterionNotFound,
NoWalletPresentation,
VerifierNotConfigured(String),
RequestNotFound,
RequestClosed,
TooManySessions,
Other(AppError),
}
impl From<AppError> for StartRefusal {
fn from(e: AppError) -> Self {
Self::Other(e)
}
}
#[derive(Debug)]
pub struct Started {
pub session: WalletSession,
pub wallet_uri: String,
}
fn prefix_of(kind: VerifierKind) -> &'static str {
match kind {
VerifierKind::Swiyu => "decentralized_identifier",
VerifierKind::Eudi => "x509_hash",
}
}
fn scheme_of(kind: VerifierKind) -> &'static str {
match kind {
VerifierKind::Swiyu => "swiyu-verify",
VerifierKind::Eudi => "haip-vp",
}
}
fn enc_values_of(kind: VerifierKind) -> Vec<ContentEncryption> {
match kind {
VerifierKind::Swiyu => vec![ContentEncryption::A256Gcm],
VerifierKind::Eudi => vec![ContentEncryption::A128Gcm, ContentEncryption::A256Gcm],
}
}
fn identity_header_of(
identity: &VerifierIdentityRecord,
signer: &dyn super::VerifierSigner,
) -> Map<String, Value> {
let mut header = Map::new();
match identity.kind {
VerifierKind::Swiyu => {
header.insert("kid".into(), json!(signer.kid()));
header.insert("profile_version".into(), json!(SWIYU_PROFILE_VERSION));
}
VerifierKind::Eudi => {
header.insert("x5c".into(), json!(identity.x5c));
}
}
header
}
async fn choose_identity(
state: &AppState,
prefix: Option<&str>,
default_identity: Option<&str>,
) -> Result<VerifierIdentityRecord, StartRefusal> {
let installed: Vec<VerifierIdentityRecord> = list_identities(&state.oid4vp_keys_ks)
.await?
.into_iter()
.filter(|r| state.oid4vp_signers.contains_key(&r.id))
.filter(|r| r.client_id().is_some())
.collect();
let chosen = match prefix {
Some(p) => installed.into_iter().find(|r| prefix_of(r.kind) == p),
None => match default_identity {
Some(id) => installed.into_iter().find(|r| r.id == id),
None => installed.into_iter().next(),
},
};
chosen.ok_or_else(|| {
StartRefusal::VerifierNotConfigured(match prefix {
Some(p) => {
format!(
"this community holds no verifier identity for client identifier prefix {p}"
)
}
None => "this community holds no OpenID4VP verifier identity".into(),
})
})
}
pub async fn start(
state: &AppState,
applicant_did: &str,
criterion_digest: &str,
request_id: Option<&str>,
prefix: Option<&str>,
now: DateTime<Utc>,
) -> Result<Started, StartRefusal> {
let (cfg, public_url, community_name) = {
let config = state.config.read().await;
(
config.oid4vp.clone(),
config.public_url.clone(),
config.vtc_name.clone(),
)
};
let served = crate::join::criteria::served_by_digest(state, criterion_digest, now)
.await?
.ok_or(StartRefusal::CriterionNotFound)?;
let query = served.stored.dcql()?;
let published_query = served
.json
.get("presentationDefinition")
.cloned()
.unwrap_or(Value::Null);
let Some(query) =
query.filter(|q| q.credentials.iter().any(|cq| cq.format == SD_JWT_VC_FORMAT))
else {
return Err(StartRefusal::NoWalletPresentation);
};
let request_id = match request_id {
None => None,
Some(raw) => {
let id = Uuid::parse_str(raw).map_err(|_| StartRefusal::RequestNotFound)?;
let request = crate::join::storage::get_join_request(&state.join_requests_ks, id)
.await?
.filter(|r| r.applicant_did == applicant_did)
.ok_or(StartRefusal::RequestNotFound)?;
if !matches!(request.status, JoinStatus::Pending | JoinStatus::Deferred) {
return Err(StartRefusal::RequestClosed);
}
Some(id)
}
};
let identity = choose_identity(state, prefix, cfg.default_identity.as_deref()).await?;
let signer = state
.oid4vp_signers
.get(&identity.id)
.cloned()
.ok_or_else(|| StartRefusal::VerifierNotConfigured(identity.id.clone()))?;
let public_url = public_url
.filter(|u| u.starts_with("https://"))
.ok_or_else(|| {
StartRefusal::VerifierNotConfigured(
"this community has no https public_url for a wallet to reach".into(),
)
})?;
let public_url = public_url.trim_end_matches('/');
if session::open_count(&state.oid4vp_sessions_ks, applicant_did, now).await?
>= cfg.max_open_sessions
{
return Err(StartRefusal::TooManySessions);
}
let id = session::random_token(24);
let nonce = session::random_token(16);
let wallet_state = session::random_token(16);
let response_key = EcdhEsDecryptionKey::generate(RESPONSE_KEY_ID);
let response_jwk = response_key
.public_jwk()
.map_err(|e| AppError::Internal(format!("response key: {e}")))?;
let encs = enc_values_of(identity.kind);
let expires_at = now + Duration::seconds(cfg.session_ttl_secs as i64);
let client_id = identity
.client_id()
.ok_or_else(|| StartRefusal::VerifierNotConfigured(identity.id.clone()))?;
let client_id = ClientId::parse(&client_id)
.map_err(|e| AppError::Internal(format!("verifier identity client_id: {e}")))?;
let request_uri = format!("{public_url}/v1/oid4vp/request/{id}");
let response_uri = format!("{public_url}/v1/oid4vp/response/{id}");
let mut additional = Map::new();
if let Some(name) = community_name {
additional.insert("client_name".into(), json!(name));
}
let mut request = RequestObject::direct_post_jwt(
&client_id,
response_uri,
nonce.clone(),
query,
VerifierClientMetadata {
jwks: json!({ "keys": [response_jwk] }),
encrypted_response_enc_values_supported: encs
.iter()
.map(|e| e.as_str().to_string())
.collect(),
vp_formats_supported: None,
additional,
},
);
request.state = Some(wallet_state.clone());
request.verifier_info = identity.verifier_info.as_deref().and_then(|entries| {
let (kept, expired) = super::trust_statement::current(entries, now.timestamp());
for s in &expired {
warn!(identity = %identity.id, expired_at = %super::trust_statement::timestamp(s.exp),
"verifier identity's {} has expired and is left out of the request; install a \
new one with `vtc oid4vp trust-statement`", s.kind.label());
}
(!kept.is_empty()).then_some(kept)
});
request.iat = Some(now.timestamp());
request.exp = Some(expires_at.timestamp());
let jar = sign_request_object(&request, &identity, signer.as_ref()).await?;
let wallet_uri = RequestObject::wallet_invocation_uri(
cfg.wallet_uri_scheme
.as_deref()
.unwrap_or(scheme_of(identity.kind)),
&client_id,
&request_uri,
)
.map_err(|e| StartRefusal::VerifierNotConfigured(e.to_string()))?;
let session = WalletSession {
id,
applicant_did: applicant_did.to_string(),
criterion_id: served.stored.id.clone(),
requirements_digest: served.digest.clone(),
credential_issuers: served
.stored
.credential_issuers
.unwrap_or(crate::schemas::accepts::CredentialIssuers::Community),
request_id,
identity_id: identity.id.clone(),
client_id: client_id.to_string(),
dcql_query: published_query,
nonce,
state: wallet_state,
enc_values: encs.iter().map(|e| e.as_str().to_string()).collect(),
response_key_id: RESPONSE_KEY_ID.into(),
response_key: Some(URL_SAFE_NO_PAD.encode(response_key.scalar_bytes())),
request_object: Some(jar),
phase: SessionPhase::AwaitingWallet,
reason: None,
evidence: None,
created_at: now,
expires_at,
closed_at: None,
held_until: None,
};
session::insert(&state.oid4vp_sessions_ks, &session).await?;
info!(
session = %session.id,
applicant = %applicant_did,
criterion = %session.criterion_id,
identity = %session.identity_id,
"OpenID4VP wallet session opened"
);
Ok(Started {
session,
wallet_uri,
})
}
async fn sign_request_object(
request: &RequestObject,
identity: &VerifierIdentityRecord,
signer: &dyn super::VerifierSigner,
) -> Result<String, AppError> {
request
.validate()
.map_err(|e| AppError::Internal(format!("request object: {e}")))?;
let mut header = identity_header_of(identity, signer);
header.insert("typ".into(), json!(REQUEST_OBJECT_TYP));
let Value::Object(claims) = serde_json::to_value(request)
.map_err(|e| AppError::Internal(format!("request object: {e}")))?
else {
return Err(AppError::Internal(
"request object: not a JSON object".into(),
));
};
signer.sign_jws(header, claims).await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalletAnswer {
Presented,
Declined,
Refused(RefusalReason),
NoSession,
}
pub async fn receive(
state: &AppState,
id: &str,
form_body: &[u8],
now: DateTime<Utc>,
) -> Result<WalletAnswer, AppError> {
let Some(claimed) = session::claim_for_response(&state.oid4vp_sessions_ks, id, now).await?
else {
return Ok(WalletAnswer::NoSession);
};
let hold = Duration::seconds(state.config.read().await.oid4vp.evidence_hold_secs as i64);
let outcome = verify_answer(state, &claimed, form_body, now).await;
let answer = match &outcome {
Ok(_) => WalletAnswer::Presented,
Err(RefusalReason::WalletDeclined) => WalletAnswer::Declined,
Err(reason) => WalletAnswer::Refused(*reason),
};
let finished = session::finish(&state.oid4vp_sessions_ks, id, outcome, hold, now).await?;
if finished.phase == SessionPhase::Presented && finished.request_id.is_some() {
let _serial = JOIN_HANDOFF.lock().await;
match crate::join::orchestrate::wallet_evidence_arrived(state, &finished).await {
Ok(Some(outcome)) => info!(
session = %finished.id,
request = %outcome.request.id,
status = %outcome.request.status,
"join request decided again on wallet evidence"
),
Ok(None) => {}
Err(e) => warn!(
session = %finished.id,
error = %e,
"wallet evidence could not be applied to its join request"
),
}
}
match &finished.evidence {
Some(evidence) => info!(
session = %finished.id,
applicant = %finished.applicant_did,
holder = %evidence.holder,
credentials = evidence.credentials.len(),
"OpenID4VP wallet presentation verified"
),
None => info!(
session = %finished.id,
applicant = %finished.applicant_did,
reason = ?finished.reason,
"OpenID4VP wallet answer refused"
),
}
Ok(answer)
}
async fn verify_answer(
state: &AppState,
session: &WalletSession,
form_body: &[u8],
now: DateTime<Utc>,
) -> Result<WalletEvidence, RefusalReason> {
let invalid = |what: &str, detail: &dyn std::fmt::Display| {
warn!(session = %session.id, %detail, "wallet response refused: {what}");
RefusalReason::PresentationInvalid
};
let scalar = session
.response_key
.as_deref()
.and_then(|k| URL_SAFE_NO_PAD.decode(k).ok())
.ok_or_else(|| invalid("response key", &"missing"))?;
let key = EcdhEsDecryptionKey::from_scalar(&session.response_key_id, &scalar)
.map_err(|e| invalid("response key", &e))?;
let allowed: Vec<ContentEncryption> = session
.enc_values
.iter()
.filter_map(|e| ContentEncryption::parse(e))
.collect();
let response = match receive_direct_post_jwt(form_body, &key, &allowed)
.map_err(|e| invalid("not a readable direct_post.jwt", &e))?
{
DirectPostJwt::Response(r) => r,
DirectPostJwt::Error(e) => {
info!(
session = %session.id,
error = %e.error,
"the wallet answered with an error"
);
return Err(RefusalReason::WalletDeclined);
}
_ => {
return Err(invalid(
"response",
&"an unrecognised direct_post.jwt shape",
));
}
};
if response.state.as_deref() != Some(session.state.as_str()) {
return Err(invalid("state", &"does not echo the session's"));
}
let mut verified = Vec::new();
for (query_id, presentations) in &response.vp_token {
for presentation in presentations {
let set = crate::credentials::verify_vp_token_with_anchors(
presentation,
&session.client_id,
&session.nonce,
state.did_resolver.as_ref(),
&state.x509_trust_anchors,
now,
)
.await
.map_err(|e| invalid("presentation", &e))?;
let format = if presentation.is_string() {
SD_JWT_VC_FORMAT
} else {
LDP_VC_FORMAT
};
for p in set.presentations {
verified.push((query_id.clone(), format, p));
}
}
}
let holder = match verified.first() {
Some((_, _, p)) => p.holder_did.clone(),
None => return Err(invalid("vp_token", &"carries no presentation")),
};
if verified.iter().any(|(_, _, p)| p.holder_did != holder) {
return Err(invalid(
"vp_token",
&"its presentations bind different holders",
));
}
let fetcher = crate::routes::join_requests::present::credential_status_fetcher(state);
for (_, _, p) in &verified {
let status = crate::routes::join_requests::present::resolve_presented_status(
p.credential_status.as_ref(),
Some(&p.issuer_did),
&fetcher,
)
.await;
if status != CredentialStatus::Valid {
warn!(session = %session.id, issuer = %p.issuer_did, ?status,
"wallet response refused: a credential's status is not valid");
return Err(RefusalReason::StatusNotValid);
}
}
let own_did = state.config.read().await.vtc_did.clone();
for (_, _, p) in &verified {
if !crate::join::criteria::issuer_admitted(
state,
own_did.as_deref(),
session.credential_issuers,
&p.issuer_did,
)
.await
{
warn!(session = %session.id, issuer = %p.issuer_did,
"wallet response refused: an issuer the criterion does not admit");
return Err(RefusalReason::IssuerNotTrusted);
}
}
let query =
DcqlQuery::from_json(&session.dcql_query).map_err(|e| invalid("stored query", &e))?;
for (query_id, _, p) in &verified {
let authorities = query
.credentials
.iter()
.find(|cq| cq.id == *query_id)
.and_then(|cq| cq.trusted_authorities.as_deref());
if let Some(authorities) = authorities
&& !crate::join::criteria::meets_trusted_authorities(
authorities,
&p.issuer_authority_key_ids,
)
{
warn!(session = %session.id, issuer = %p.issuer_did,
"wallet response refused: no authority the query trusts certifies its issuer");
return Err(RefusalReason::IssuerNotTrusted);
}
}
let mut answered: BTreeMap<String, Vec<CandidateCredential>> = BTreeMap::new();
for (n, (query_id, format, p)) in verified.iter().enumerate() {
answered
.entry(query_id.clone())
.or_default()
.push(CandidateCredential {
id: n.to_string(),
format: (*format).to_string(),
claims: p.claims.clone(),
vct: p.vct.clone(),
doctype: None,
supports_holder_binding: p.holder_bound,
});
}
if let Err(e) = query.evaluate_presentation(&answered) {
warn!(session = %session.id, error = %e,
"wallet response refused: it does not satisfy the query");
return Err(RefusalReason::QueryNotSatisfied);
}
Ok(WalletEvidence {
satisfied: true,
holder,
credentials: verified
.into_iter()
.map(|(credential_query_id, format, p)| EvidenceCredential {
credential_query_id,
format: format.to_string(),
issuer: p.issuer_did,
vct: p.vct,
claims: p.claims,
issuer_authority_key_ids: p.issuer_authority_key_ids,
})
.collect(),
})
}