use serde::Serialize;
use serde_json::Value as JsonValue;
use tracing::{info, warn};
use uuid::Uuid;
use affinidi_vc::VerifiableCredential;
use vti_common::audit::{
AuditEvent, AuditWriter, CredentialIssuedData, JoinRequestData, JoinRequestRejectedData,
JoinRequestSupplementedData, JoinRequestWithdrawnData, MemberAddedData,
};
use vti_common::error::AppError;
use crate::ceremony::execute::{self, AdmitOutcome, top_level_id};
use crate::ceremony::facts::Invitation;
use crate::ceremony::{
Credential, CredentialStatus, EffectOutcome, EffectPlan, Evidence, Facts, FactsInputs,
Presentation, Purpose, Verdict, VerifiedFacts, assemble_facts,
};
use crate::credentials::invitation_verify::{
ConsumedInvitation, mark_consumed, release_consumed, verify_presented_invitation,
};
use crate::credentials::vac::VAC_TYPE;
use crate::credentials::vmc::VMC_TYPE;
use crate::join::criteria::{self, CriterionRefusal, Governing, Presented};
use crate::join::{
JoinDecision, JoinRequest, JoinStatus, JoinTransport, list_join_requests, store_join_request,
};
use crate::policy::{PolicyPurpose, extract::extract_vp_claims, load_active_compiled};
use crate::server::AppState;
use crate::vetting::challenge_refusal::ChallengeRefusal;
pub const JOIN_REQUEST_SUBMIT_DOMAIN_TAG: &[u8] = b"vtc-join-request/v1\0";
const JOIN_SUBMIT_FRESHNESS_SECS: i64 = 300;
const JOIN_SUBMIT_FUTURE_SKEW_SECS: i64 = 60;
pub struct HolderBinding<'a> {
pub signature_hex: &'a str,
pub audience: &'a str,
pub created: i64,
}
pub struct JoinSubmitOutcome {
pub request: JoinRequest,
pub admit: Option<Box<AdmitOutcome>>,
}
#[derive(Debug)]
pub enum SubmitRefusal {
AlreadyOpen {
request_id: Uuid,
status: JoinStatus,
},
AttributesMissing(Vec<String>),
AttributesUnrequested(Vec<String>),
PresentationInvalid(String),
NotAccepting,
CriterionUnknown(String),
Challenge(ChallengeRefusal),
Other(AppError),
}
impl From<CriterionRefusal> for SubmitRefusal {
fn from(r: CriterionRefusal) -> Self {
match r {
CriterionRefusal::NotAccepting => Self::NotAccepting,
CriterionRefusal::Unknown(digest) => Self::CriterionUnknown(digest),
CriterionRefusal::Challenge(r) => Self::Challenge(r),
CriterionRefusal::Other(e) => Self::Other(e),
}
}
}
impl From<AppError> for SubmitRefusal {
fn from(e: AppError) -> Self {
Self::Other(e)
}
}
impl From<SubmitRefusal> for AppError {
fn from(r: SubmitRefusal) -> Self {
match r {
SubmitRefusal::AlreadyOpen { request_id, status } => AppError::Conflict(format!(
"an open join request already exists (id {request_id}, status {status}); \
withdraw it with vtc/join-requests/withdraw/0.1, or await its decision, \
before resubmitting"
)),
SubmitRefusal::AttributesMissing(types) => AppError::Validation(format!(
"this community asks for {} and the submission does not answer it; add it and \
resubmit",
types.join(", ")
)),
SubmitRefusal::AttributesUnrequested(types) => AppError::Validation(format!(
"this community does not ask for {}; remove it and resubmit — nothing was stored",
types.join(", ")
)),
SubmitRefusal::PresentationInvalid(reason) => AppError::Validation(reason),
SubmitRefusal::NotAccepting => AppError::Validation(
"this community publishes no join criteria, so it is not accepting applications \
at present"
.into(),
),
SubmitRefusal::CriterionUnknown(digest) => AppError::Validation(format!(
"criterion {digest} names no join criterion this community publishes; read \
vtc/join-requests/manifest/0.3 again and resubmit under a current one"
)),
SubmitRefusal::Challenge(r) => r.into(),
SubmitRefusal::Other(e) => e,
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn submit_inner(
state: &AppState,
applicant_did: String,
vp: JsonValue,
registry_consent: bool,
extensions: JsonValue,
attributes: Vec<super::SubmittedAttribute>,
criterion: Option<String>,
binding: Option<HolderBinding<'_>>,
transport: JoinTransport,
) -> Result<JoinSubmitOutcome, SubmitRefusal> {
if let Some(b) = binding.as_ref() {
let vtc_did = state
.config
.read()
.await
.vtc_did
.clone()
.ok_or_else(|| AppError::Internal("vtc_did not configured".into()))?;
if b.audience != vtc_did {
return Err(AppError::Validation(format!(
"join-request audience ({}) does not match this VTC ({vtc_did})",
b.audience
))
.into());
}
let now = crate::auth::session::now_epoch() as i64;
if b.created < now - JOIN_SUBMIT_FRESHNESS_SECS
|| b.created > now + JOIN_SUBMIT_FUTURE_SKEW_SECS
{
return Err(AppError::Validation(
"join-request `created` is outside the freshness window — re-sign and resubmit"
.into(),
)
.into());
}
verify_holder_signature(
&applicant_did,
&vp,
registry_consent,
&extensions,
b.audience,
b.created,
b.signature_hex,
)?;
}
if let Err(reason) = check_presentation_holder(&applicant_did, &vp) {
return Err(SubmitRefusal::PresentationInvalid(reason));
}
let requested =
crate::community::requested_attributes::load_requested(&state.community_ks).await?;
let answers: Vec<crate::community::requested_attributes::Answer> = attributes
.iter()
.map(|a| (a.r#type.clone(), a.value.clone()))
.collect();
match crate::community::requested_attributes::check_answers(&requested, &answers) {
Ok(()) => {}
Err(crate::community::requested_attributes::AnswersRefused::Missing(t)) => {
return Err(SubmitRefusal::AttributesMissing(t));
}
Err(crate::community::requested_attributes::AnswersRefused::Unrequested(t)) => {
return Err(SubmitRefusal::AttributesUnrequested(t));
}
}
if let Some(existing) = find_open_request(&state.join_requests_ks, &applicant_did).await? {
let status = crate::join::storage::get_join_request(&state.join_requests_ks, existing)
.await?
.map_or(JoinStatus::Pending, |r| r.status);
return Err(SubmitRefusal::AlreadyOpen {
request_id: existing,
status,
});
}
let vp_claims = extract_vp_claims(&vp);
let invitation_fact = match verify_presented_invitation(state, &applicant_did, &vp).await? {
Some(vi) => {
let consumed = crate::credentials::invitation_verify::is_consumed(
&state.consumed_invitations_ks,
&vi.id,
)
.await?;
Some((vi.id.clone(), vi.to_fact(consumed)))
}
None => None,
};
let consume_invitation_id = invitation_fact.as_ref().map(|(id, _)| id.clone());
let invitation = invitation_fact.map(|(_, fact)| fact);
match &invitation {
Some(inv) => {
let own_did = state.config.read().await.vtc_did.clone();
info!(
applicant = %applicant_did,
vic_issuer = %inv.issuer,
vtc_own_did = own_did.as_deref().unwrap_or("<unset>"),
verified = inv.verified,
issuer_trusted = inv.issuer_trusted,
consumed = inv.consumed,
"join: invitation presented — auto-admit needs verified && issuer_trusted && !consumed"
);
}
None => {
if let Some(vc) = vp.get("verifiableCredential") {
let cred_types: Vec<String> = vc
.as_array()
.map(|arr| {
arr.iter()
.map(|c| {
c.get("type")
.map(|t| t.to_string())
.unwrap_or_else(|| "<no type>".to_string())
})
.collect()
})
.unwrap_or_default();
info!(
applicant = %applicant_did,
is_array = vc.is_array(),
credential_types = ?cred_types,
"join: VP carried `verifiableCredential` but no InvitationCredential was \
extracted — auto-admit-on-invitation cannot fire"
);
}
}
}
let now = chrono::Utc::now();
let held =
crate::oid4vp::session::held_for(&state.oid4vp_sessions_ks, &applicant_did, now).await?;
let mut credentials =
criteria::verify_embedded_credentials(state, &applicant_did, &vp, now).await?;
for session in &held {
if let Some(evidence) = &session.evidence {
credentials.extend(criteria::presented_from_wallet(
&session.requirements_digest,
&evidence.credentials,
));
}
}
let presented = Presented {
credentials,
invitation: invitation.clone(),
vp: Some(vp.clone()),
extensions: extensions.clone(),
};
let governing =
criteria::govern(state, &applicant_did, &presented, criterion.as_deref(), now).await?;
let consume_invitation_id = consume_invitation_id.filter(|_| uses_invitation(&governing));
let presentation = presentation_from_vp(&applicant_did, &vp);
let vetting_record = governing.vetting.clone();
let verdict = decide_join(
state,
&applicant_did,
presentation,
invitation,
&governing,
None,
)
.await?;
let outcome = realize_join_verdict(
state,
&applicant_did,
vp,
vp_claims,
registry_consent,
extensions,
attributes,
verdict,
transport,
consume_invitation_id,
)
.await?;
record_criterion(state, outcome.request.id, &governing).await;
if let Some(facts) = vetting_record
&& let Err(e) = super::storage::store_vetting_facts(
&state.join_requests_ks,
outcome.request.id,
&facts,
chrono::Utc::now(),
)
.await
{
warn!(
request = %outcome.request.id,
error = %e,
"vetting facts not recorded for a decided join request"
);
}
let claimed: Vec<String> = held
.iter()
.filter(|s| s.requirements_digest == governing.fact.requirements_digest)
.map(|s| s.id.clone())
.collect();
if !claimed.is_empty() {
record_wallet_evidence(state, outcome.request.id, &claimed, now).await;
}
Ok(outcome)
}
async fn record_wallet_evidence(
state: &AppState,
request_id: Uuid,
session_ids: &[String],
now: chrono::DateTime<chrono::Utc>,
) {
let bound = match crate::oid4vp::session::bind_to_request(
&state.oid4vp_sessions_ks,
session_ids,
request_id,
now,
)
.await
{
Ok(bound) => bound,
Err(e) => {
warn!(request = %request_id, error = %e, "wallet sessions not bound to a join request");
return;
}
};
for session in bound {
if let Some(item) = session.evidence_item()
&& let Err(e) =
super::storage::add_wallet_evidence(&state.join_requests_ks, request_id, item).await
{
warn!(request = %request_id, error = %e, "wallet evidence not recorded on a join request");
}
}
}
pub async fn decide_join(
state: &AppState,
applicant_did: &str,
presentation: Presentation,
invitation: Option<Invitation>,
governing: &Governing,
thread_id: Option<&str>,
) -> Result<Verdict, AppError> {
let facts = assemble_join_facts(
state,
applicant_did,
presentation,
invitation,
governing,
thread_id,
)
.await?;
let verified = VerifiedFacts::assemble(facts)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Join,
)
.await?;
let mut verdict = crate::ceremony::decide(&verified, &policy)?;
if let Verdict::RequestMore(more) = &mut verdict {
criteria::expand_needs(&mut more.needs, Some(&governing.fact));
crate::vetting::expand_needs(&mut more.needs, governing.vetting.as_ref());
if more.presentation_definition.is_null()
&& let Some(query) = governing.presentation_definition()
{
more.presentation_definition = query;
}
}
Ok(verdict)
}
fn uses_invitation(governing: &Governing) -> bool {
let stored = &governing.served.stored;
stored.invitation_required
|| stored.vetting.as_ref().is_some_and(|v| {
matches!(
v.invitation,
Some(vta_sdk::protocols::vetting::VettingRequirementsInvitation::Required)
)
})
}
pub(crate) async fn record_criterion(state: &AppState, request_id: Uuid, governing: &Governing) {
if let Err(e) = super::storage::store_criterion(
&state.join_requests_ks,
request_id,
&governing.fact,
chrono::Utc::now(),
)
.await
{
warn!(
request = %request_id,
criterion = %governing.fact.id,
error = %e,
"governing criterion not recorded for a decided join request"
);
}
info!(
request = %request_id,
criterion = %governing.fact.id,
digest = %governing.fact.requirements_digest,
admission = governing.fact.admission.as_str(),
met = governing.fact.met,
superseded = governing.fact.superseded,
"join request decided under criterion"
);
}
#[allow(clippy::too_many_arguments)]
async fn apply_verdict_to_request(
state: &AppState,
request: &mut JoinRequest,
verdict: &Verdict,
applicant_did: &str,
consume_invitation_id: Option<String>,
audit_writer: &AuditWriter,
) -> Result<Option<Box<AdmitOutcome>>, AppError> {
let mut admit: Option<Box<AdmitOutcome>> = None;
match verdict {
Verdict::Allow(allow) => {
let role = allow.role.clone().unwrap_or_else(|| "member".to_string());
let plan = EffectPlan::Admit {
subject: applicant_did.to_string(),
role: role.clone(),
obligations: allow.obligations.clone(),
publish_consent: request.registry_consent,
label: None,
};
let claimed = match consume_invitation_id.as_deref() {
Some(vic_id) => {
let record = ConsumedInvitation {
applicant: applicant_did.to_string(),
consumed_at: chrono::Utc::now(),
via_join_request_id: request.id.to_string(),
};
if !mark_consumed(&state.consumed_invitations_ks, vic_id, &record).await? {
warn!(
vic_id = %vic_id,
applicant = %applicant_did,
"invitation consumed by a concurrent redeem; not admitting on it",
);
return Err(AppError::Conflict(
"this invitation has already been redeemed".into(),
));
}
Some((vic_id, record))
}
None => None,
};
let applied = execute::apply(state, plan, applicant_did).await;
if !matches!(applied, Ok(EffectOutcome::Admitted(_)))
&& let Some((vic_id, record)) = &claimed
{
match release_consumed(&state.consumed_invitations_ks, vic_id, record).await {
Ok(_) => {}
Err(e) => warn!(
vic_id = %vic_id,
error = %e,
"admission failed and the invitation claim could not be released",
),
}
}
if let EffectOutcome::Admitted(creds) = applied? {
if let Err(e) = crate::credentials::delivery::deliver_membership_credentials(
state,
applicant_did,
&creds,
)
.await
{
warn!(
applicant = %applicant_did,
error = %e,
"membership-credential delivery failed on auto-admit; credentials issued",
);
}
emit_admit_audit(
audit_writer,
applicant_did,
applicant_did,
&creds,
&role,
Some(request.id.to_string()),
)
.await?;
admit = Some(creds);
if claimed.is_some() {
match crate::members::storage::edit_member(
&state.members_ks,
applicant_did,
|m| {
m.joined_via_invitation = true;
true
},
)
.await
{
Ok(Some(_)) => {}
Ok(None) => warn!(
applicant = %applicant_did,
"admitted member row missing when flagging joined_via_invitation",
),
Err(e) => warn!(
applicant = %applicant_did,
error = %e,
"failed to flag member joined_via_invitation",
),
}
}
}
request.status = JoinStatus::Approved;
}
Verdict::Refer(_) => request.status = JoinStatus::Pending,
Verdict::RequestMore(_) => {
request.status = JoinStatus::Deferred;
request.policy_decision = Some(serde_json::to_value(verdict)?);
}
Verdict::Deny(d) => {
request.status = JoinStatus::Rejected;
request.policy_decision = Some(serde_json::to_value(verdict)?);
request.decision = Some(JoinDecision {
code: d.code.clone(),
reason: d.reason.clone(),
decided_at: chrono::Utc::now(),
});
}
}
Ok(admit)
}
pub async fn realize_join_verdict(
state: &AppState,
applicant_did: &str,
vp: JsonValue,
vp_claims: JsonValue,
registry_consent: bool,
extensions: JsonValue,
attributes: Vec<super::SubmittedAttribute>,
verdict: Verdict,
transport: JoinTransport,
consume_invitation_id: Option<String>,
) -> Result<JoinSubmitOutcome, AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let mut request = JoinRequest::new(applicant_did.to_string(), vp);
request.vp_claims = vp_claims;
request.registry_consent = registry_consent;
let mut extensions = extensions;
crate::vetting::redact_hidden_submission(&mut extensions);
request.extensions = extensions;
request.attributes = attributes;
let rejected = matches!(verdict, Verdict::Deny(_));
let admit = apply_verdict_to_request(
state,
&mut request,
&verdict,
applicant_did,
consume_invitation_id,
audit_writer,
)
.await?;
store_join_request(&state.join_requests_ks, &request).await?;
crate::admin_actions::queues::join_changed(state, &request).await;
if rejected {
audit_writer
.write(
applicant_did,
None,
AuditEvent::JoinRequestRejected(JoinRequestRejectedData {
request_id: request.id.to_string(),
reason: "policy denied".into(),
policy_decision: request.policy_decision.clone(),
}),
)
.await?;
} else {
audit_writer
.write(
applicant_did,
None,
AuditEvent::JoinRequestSubmitted(JoinRequestData {
request_id: request.id.to_string(),
transport: transport.as_str().to_string(),
}),
)
.await?;
}
info!(
request_id = %request.id,
applicant = %applicant_did,
transport = transport.as_str(),
verdict = verdict.effect(),
"join request realized"
);
Ok(JoinSubmitOutcome { request, admit })
}
async fn assemble_join_facts(
state: &AppState,
applicant_did: &str,
presentation: Presentation,
invitation: Option<Invitation>,
governing: &Governing,
thread_id: Option<&str>,
) -> Result<Facts, AppError> {
assemble_facts(
state,
FactsInputs {
purpose: Purpose::Join,
actor_did: applicant_did.to_string(),
actor_role: None,
subject_did: applicant_did.to_string(),
subject_member: None,
evidence: Evidence {
vetting: governing.vetting.clone(),
invitation,
presentation: Some(presentation),
request: None,
criterion: Some(governing.fact.clone()),
},
thread_id: thread_id.map(str::to_string),
},
)
.await
}
fn vp_holder(vp: &JsonValue) -> Option<&str> {
match vp.get("holder")? {
JsonValue::String(s) => Some(s.as_str()),
JsonValue::Object(o) => o.get("id").and_then(|i| i.as_str()),
_ => None,
}
}
fn check_presentation_holder(applicant_did: &str, vp: &JsonValue) -> Result<(), String> {
let Some(holder) = vp_holder(vp) else {
return Ok(());
};
let holder_base = holder.split('#').next().unwrap_or(holder);
if holder_base == applicant_did {
Ok(())
} else {
Err(format!(
"the presentation's holder ({holder_base}) is not the submitting applicant \
({applicant_did}); a join presentation must be the applicant's own"
))
}
}
fn presentation_from_vp(applicant_did: &str, vp: &JsonValue) -> Presentation {
let holder = vp
.get("holder")
.and_then(|h| match h {
JsonValue::String(s) => Some(s.clone()),
JsonValue::Object(o) => o.get("id").and_then(|i| i.as_str()).map(str::to_string),
_ => None,
})
.unwrap_or_else(|| applicant_did.to_string());
let credentials = vp
.get("verifiableCredential")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(credential_from_vc).collect())
.unwrap_or_default();
Presentation {
verified: true,
holder,
credentials,
}
}
fn credential_from_vc(vc: &JsonValue) -> Option<Credential> {
let obj = vc.as_object()?;
let credential_type = crate::credentials::ingress::concrete_type(vc)
.unwrap_or_else(|| "VerifiableCredential".to_string());
let predicate = crate::credentials::ingress::statement_predicate(vc);
let issuer = match obj.get("issuer") {
Some(JsonValue::String(s)) => s.clone(),
Some(JsonValue::Object(o)) => o
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string(),
_ => String::new(),
};
Some(Credential {
credential_type,
predicate,
issuer,
issuer_trusted: false,
status: CredentialStatus::Unknown,
holder_bound: false,
claims: JsonValue::Null,
valid_until: None,
witness_binding: None,
task_context: None,
})
}
#[allow(clippy::too_many_arguments)]
fn verify_holder_signature(
applicant_did: &str,
vp: &JsonValue,
registry_consent: bool,
extensions: &JsonValue,
audience: &str,
created: i64,
signature_hex: &str,
) -> Result<(), AppError> {
let payload = canonical_payload(
applicant_did,
vp,
registry_consent,
extensions,
audience,
created,
)?;
crate::holder_signature::verify_domain_signed(
applicant_did,
JOIN_REQUEST_SUBMIT_DOMAIN_TAG,
&payload,
signature_hex,
)
.map_err(AppError::Validation)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CanonicalPayload<'a> {
applicant_did: &'a str,
vp: &'a JsonValue,
registry_consent: bool,
extensions: &'a JsonValue,
audience: &'a str,
created: i64,
}
fn canonical_payload(
applicant_did: &str,
vp: &JsonValue,
registry_consent: bool,
extensions: &JsonValue,
audience: &str,
created: i64,
) -> Result<Vec<u8>, AppError> {
serde_json::to_vec(&CanonicalPayload {
applicant_did,
vp,
registry_consent,
extensions,
audience,
created,
})
.map_err(|e| AppError::Internal(format!("canonical payload serialize: {e}")))
}
pub async fn find_open_request(
ks: &vti_common::store::KeyspaceHandle,
applicant_did: &str,
) -> Result<Option<Uuid>, AppError> {
let all = list_join_requests(ks).await?;
Ok(all
.into_iter()
.find(|r| {
r.applicant_did == applicant_did
&& matches!(r.status, JoinStatus::Pending | JoinStatus::Deferred)
})
.map(|r| r.id))
}
pub async fn emit_admit_audit(
audit_writer: &AuditWriter,
actor_did: &str,
subject_did: &str,
creds: &AdmitOutcome,
role: &str,
via_join_request_id: Option<String>,
) -> Result<(), AppError> {
audit_writer
.write(
actor_did,
Some(subject_did),
AuditEvent::MemberAdded(MemberAddedData {
role: role.to_string(),
via_join_request_id,
}),
)
.await?;
audit_writer
.write(
actor_did,
Some(subject_did),
AuditEvent::VmcIssued(credential_issued_data(
&creds.vmc,
Some(creds.status_list_index),
)?),
)
.await?;
audit_writer
.write(
actor_did,
Some(subject_did),
AuditEvent::VecIssued(credential_issued_data(&creds.role_vac, None)?),
)
.await?;
Ok(())
}
pub(crate) fn credential_issued_data(
vc: &VerifiableCredential,
status_list_index: Option<u32>,
) -> Result<CredentialIssuedData, AppError> {
let id = top_level_id(vc).ok_or_else(|| {
AppError::Internal("credential is missing top-level `id` — issuance dropped it".into())
})?;
let credential_type = vc
.types
.iter()
.find(|t| *t == VMC_TYPE || *t == VAC_TYPE)
.cloned()
.ok_or_else(|| AppError::Internal("credential carries neither VMC nor VAC type".into()))?;
let valid_from = vc
.valid_from
.clone()
.ok_or_else(|| AppError::Internal("credential missing validFrom".into()))?;
let valid_until = vc
.valid_until
.clone()
.ok_or_else(|| AppError::Internal("credential missing validUntil".into()))?;
Ok(CredentialIssuedData {
credential_id: id,
credential_type,
valid_from,
valid_until,
status_list_index,
})
}
#[cfg(test)]
fn signing_bytes(payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(JOIN_REQUEST_SUBMIT_DOMAIN_TAG.len() + payload.len());
buf.extend_from_slice(JOIN_REQUEST_SUBMIT_DOMAIN_TAG);
buf.extend_from_slice(payload);
buf
}
#[derive(Debug)]
pub enum SupplementRefusal {
NotFound(String),
NotAwaitingEvidence {
request_id: Uuid,
status: JoinStatus,
},
AlreadyDecided {
request_id: Uuid,
status: JoinStatus,
},
Challenge(ChallengeRefusal),
Other(AppError),
}
impl From<AppError> for SupplementRefusal {
fn from(e: AppError) -> Self {
Self::Other(e)
}
}
impl From<SupplementRefusal> for AppError {
fn from(r: SupplementRefusal) -> Self {
match r {
SupplementRefusal::NotFound(m) => AppError::NotFound(m),
SupplementRefusal::NotAwaitingEvidence { request_id, status } => {
AppError::Conflict(format!(
"join request {request_id} is {status}, not deferred — the community has not \
asked for more evidence, so there is nothing to supplement"
))
}
SupplementRefusal::AlreadyDecided { request_id, status } => AppError::Gone(format!(
"join request {request_id} is already {status} and cannot be supplemented"
)),
SupplementRefusal::Challenge(r) => r.into(),
SupplementRefusal::Other(e) => e,
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn supplement_inner(
state: &AppState,
applicant_did: &str,
request_id: Option<Uuid>,
vp: JsonValue,
extensions: JsonValue,
transport: JoinTransport,
) -> Result<JoinSubmitOutcome, SupplementRefusal> {
let ks = &state.join_requests_ks;
let not_found =
|| SupplementRefusal::NotFound(format!("no open join request for {applicant_did}"));
let id = match request_id {
Some(id) => id,
None => find_open_request(ks, applicant_did)
.await?
.ok_or_else(not_found)?,
};
let request = crate::join::storage::get_join_request(ks, id)
.await?
.ok_or_else(not_found)?;
if request.applicant_did != applicant_did {
return Err(not_found());
}
let previous_status = request.status;
match previous_status {
JoinStatus::Deferred => {}
JoinStatus::Pending => {
return Err(SupplementRefusal::NotAwaitingEvidence {
request_id: id,
status: previous_status,
});
}
decided => {
return Err(SupplementRefusal::AlreadyDecided {
request_id: id,
status: decided,
});
}
}
redecide(state, request, applicant_did, vp, extensions, transport).await
}
async fn redecide(
state: &AppState,
mut request: JoinRequest,
applicant_did: &str,
vp: JsonValue,
extensions: JsonValue,
transport: JoinTransport,
) -> Result<JoinSubmitOutcome, SupplementRefusal> {
let ks = &state.join_requests_ks;
let id = request.id;
let previous_status = request.status;
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let invitation_fact = match verify_presented_invitation(state, applicant_did, &vp).await? {
Some(vi) => {
let consumed = crate::credentials::invitation_verify::is_consumed(
&state.consumed_invitations_ks,
&vi.id,
)
.await?;
Some((vi.id.clone(), vi.to_fact(consumed)))
}
None => None,
};
let consume_invitation_id = invitation_fact.as_ref().map(|(id, _)| id.clone());
let invitation = invitation_fact.map(|(_, fact)| fact);
check_presentation_holder(applicant_did, &vp).map_err(AppError::Validation)?;
let now = chrono::Utc::now();
let recorded = super::storage::get_criterion(ks, id).await?;
let mut credentials =
criteria::verify_embedded_credentials(state, applicant_did, &vp, now).await?;
credentials.extend(
super::storage::get_wallet_evidence(ks, id)
.await?
.presented(),
);
let presented = Presented {
credentials,
invitation: invitation.clone(),
vp: Some(vp.clone()),
extensions: extensions.clone(),
};
let recorded_id = recorded
.as_ref()
.map_or_else(|| "<unrecorded>".to_string(), |r| r.criterion.id.clone());
let governed = match &recorded {
Some(r) => {
criteria::govern_again(
state,
applicant_did,
&presented,
&r.criterion.id,
&r.criterion.requirements_digest,
now,
)
.await
}
None => criteria::govern(state, applicant_did, &presented, None, now).await,
};
let governing = match governed {
Ok(g) => g,
Err(CriterionRefusal::Unknown(_)) => {
return Err(SupplementRefusal::Other(AppError::Gone(format!(
"the criterion join request {id} was decided under (`{}`) is no longer \
published; withdraw it and submit again under a current criterion",
recorded_id
))));
}
Err(CriterionRefusal::NotAccepting) => {
return Err(SupplementRefusal::Other(AppError::Gone(
"this community is no longer accepting applications".into(),
)));
}
Err(CriterionRefusal::Challenge(r)) => return Err(SupplementRefusal::Challenge(r)),
Err(CriterionRefusal::Other(e)) => return Err(e.into()),
};
let consume_invitation_id = consume_invitation_id.filter(|_| uses_invitation(&governing));
let presentation = presentation_from_vp(applicant_did, &vp);
let vetting_record = governing.vetting.clone();
let verdict = decide_join(
state,
applicant_did,
presentation,
invitation,
&governing,
None,
)
.await?;
request.vp_claims = extract_vp_claims(&vp);
request.vp = vp;
let mut extensions = extensions;
crate::vetting::redact_hidden_submission(&mut extensions);
request.extensions = extensions;
let admit = apply_verdict_to_request(
state,
&mut request,
&verdict,
applicant_did,
consume_invitation_id,
audit_writer,
)
.await?;
store_join_request(ks, &request).await?;
crate::admin_actions::queues::join_changed(state, &request).await;
audit_writer
.write(
applicant_did,
Some(applicant_did),
AuditEvent::JoinRequestSupplemented(JoinRequestSupplementedData {
request_id: id.to_string(),
verdict_effect: verdict.effect().to_string(),
previous_status: previous_status.to_string(),
}),
)
.await?;
record_criterion(state, id, &governing).await;
if let Some(facts) = vetting_record
&& let Err(e) =
super::storage::store_vetting_facts(ks, id, &facts, chrono::Utc::now()).await
{
warn!(
request = %id,
error = %e,
"vetting facts not recorded for a supplemented join request"
);
}
info!(
request_id = %id,
applicant = %applicant_did,
transport = transport.as_str(),
verdict = verdict.effect(),
previous_status = %previous_status,
"join request supplemented"
);
Ok(JoinSubmitOutcome { request, admit })
}
pub async fn wallet_evidence_arrived(
state: &AppState,
session: &crate::oid4vp::session::WalletSession,
) -> Result<Option<JoinSubmitOutcome>, AppError> {
let (Some(request_id), Some(item)) = (session.request_id, session.evidence_item()) else {
return Ok(None);
};
let ks = &state.join_requests_ks;
let Some(request) = super::storage::get_join_request(ks, request_id).await? else {
return Ok(None);
};
if request.applicant_did != session.applicant_did
|| !matches!(request.status, JoinStatus::Pending | JoinStatus::Deferred)
{
return Ok(None);
}
super::storage::add_wallet_evidence(ks, request_id, item).await?;
if request.status != JoinStatus::Deferred {
info!(request = %request_id, session = %session.id,
"wallet evidence recorded on a pending join request for its reviewer");
return Ok(None);
}
let applicant = request.applicant_did.clone();
let vp = request.vp.clone();
let extensions = request.extensions.clone();
redecide(
state,
request,
&applicant,
vp,
extensions,
JoinTransport::Rest,
)
.await
.map(Some)
.map_err(AppError::from)
}
pub async fn withdraw_inner(
state: &AppState,
applicant_did: &str,
request_id: Option<Uuid>,
reason: Option<String>,
) -> Result<JoinRequest, AppError> {
let ks = &state.join_requests_ks;
let id = match request_id {
Some(id) => id,
None => find_open_request(ks, applicant_did).await?.ok_or_else(|| {
AppError::NotFound(format!("no open join request for {applicant_did}"))
})?,
};
let mut request = crate::join::storage::get_join_request(ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("no open join request for {applicant_did}")))?;
if request.applicant_did != applicant_did {
return Err(AppError::NotFound(format!(
"no open join request for {applicant_did}"
)));
}
match request.status {
JoinStatus::Pending | JoinStatus::Deferred => {}
decided => {
return Err(AppError::Gone(format!(
"join request {id} is already {decided} and cannot be withdrawn"
)));
}
}
let previous_status = request.status.to_string();
request.status = JoinStatus::Withdrawn;
crate::join::storage::store_join_request(ks, &request).await?;
crate::admin_actions::queues::join_changed(state, &request).await;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
applicant_did,
Some(applicant_did),
AuditEvent::JoinRequestWithdrawn(JoinRequestWithdrawnData {
request_id: id.to_string(),
reason: reason.filter(|r| !r.trim().is_empty()),
previous_status,
}),
)
.await?;
}
tracing::info!(
request_id = %id,
applicant = %applicant_did,
"join request withdrawn by its applicant"
);
Ok(request)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn pair() -> (SigningKey, String) {
let sk = SigningKey::from_bytes(&[0xAB; 32]);
let pub_bytes = sk.verifying_key().to_bytes();
let did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&pub_bytes);
(sk, did)
}
const AUD: &str = "did:key:zThisVtc";
const CREATED: i64 = 1_900_000_000;
#[test]
fn sign_then_verify_round_trip() {
let (sk, did) = pair();
let vp = serde_json::json!({"vp":"placeholder"});
let payload = canonical_payload(&did, &vp, false, &JsonValue::Null, AUD, CREATED).unwrap();
let sig = sk.sign(&signing_bytes(&payload));
let sig_hex = hex::encode(sig.to_bytes());
verify_holder_signature(&did, &vp, false, &JsonValue::Null, AUD, CREATED, &sig_hex)
.unwrap();
}
#[test]
fn verify_rejects_wrong_signer() {
let (_a_sk, a_did) = pair();
let other = SigningKey::from_bytes(&[0xCD; 32]);
let vp = serde_json::json!({});
let payload =
canonical_payload(&a_did, &vp, false, &JsonValue::Null, AUD, CREATED).unwrap();
let sig = other.sign(&signing_bytes(&payload));
let sig_hex = hex::encode(sig.to_bytes());
let err =
verify_holder_signature(&a_did, &vp, false, &JsonValue::Null, AUD, CREATED, &sig_hex)
.expect_err("wrong signer must fail");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn verify_rejects_tampered_payload() {
let (sk, did) = pair();
let vp = serde_json::json!({"vp":"original"});
let payload = canonical_payload(&did, &vp, false, &JsonValue::Null, AUD, CREATED).unwrap();
let sig = sk.sign(&signing_bytes(&payload));
let sig_hex = hex::encode(sig.to_bytes());
let tampered = serde_json::json!({"vp":"changed"});
let err = verify_holder_signature(
&did,
&tampered,
false,
&JsonValue::Null,
AUD,
CREATED,
&sig_hex,
)
.expect_err("tampered VP must fail");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn verify_rejects_tampered_audience() {
let (sk, did) = pair();
let vp = serde_json::json!({"vp":"x"});
let payload = canonical_payload(&did, &vp, false, &JsonValue::Null, AUD, CREATED).unwrap();
let sig = sk.sign(&signing_bytes(&payload));
let sig_hex = hex::encode(sig.to_bytes());
let err = verify_holder_signature(
&did,
&vp,
false,
&JsonValue::Null,
"did:key:zOtherVtc",
CREATED,
&sig_hex,
)
.expect_err("re-pointed audience must fail the signature");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn verify_rejects_garbage_signature() {
let (_sk, did) = pair();
let err = verify_holder_signature(
&did,
&JsonValue::Null,
false,
&JsonValue::Null,
AUD,
CREATED,
"not-hex",
)
.expect_err("garbage sig must fail");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn verify_rejects_non_did_key_applicant() {
let err = verify_holder_signature(
"did:web:example.com",
&JsonValue::Null,
false,
&JsonValue::Null,
AUD,
CREATED,
"00",
)
.expect_err("non-did:key must fail");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn presentation_from_vp_does_not_present_unverified_vc_claims() {
let applicant = "did:key:zApplicant";
let vp = serde_json::json!({
"type": "VerifiablePresentation",
"holder": applicant,
"verifiableCredential": [
{
"issuer": "did:key:zForgedIssuer",
"type": ["VerifiableCredential", "EmailCredential"],
"credentialSubject": { "email": "ceo@acme.com" }
}
]
});
let p = presentation_from_vp(applicant, &vp);
assert!(p.verified, "holder-binding is verified at the route");
assert_eq!(p.holder, applicant);
assert_eq!(p.credentials.len(), 1);
let c = &p.credentials[0];
assert_eq!(c.credential_type, "EmailCredential");
assert_eq!(c.issuer, "did:key:zForgedIssuer");
assert!(!c.issuer_trusted, "issuer not vetted on the raw path");
assert!(!c.holder_bound, "no per-credential holder proof checked");
assert_eq!(
c.status,
CredentialStatus::Unknown,
"status list was never read — must not claim `valid`"
);
assert_eq!(
c.claims,
JsonValue::Null,
"unverified VC claims must NOT be surfaced to the policy"
);
}
#[test]
fn presentation_from_vp_with_no_embedded_vcs_is_holder_binding_only() {
let applicant = "did:key:zApplicant";
let vp = serde_json::json!({ "type": "VerifiablePresentation", "holder": applicant });
let p = presentation_from_vp(applicant, &vp);
assert!(p.verified);
assert!(p.credentials.is_empty());
}
}