use affinidi_data_integrity::{DataIntegrityProof, VerifyOptions};
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, MembershipReciprocatedData};
use vti_common::error::AppError;
use crate::join::{JoinStatus, JoinTransport, get_join_request};
use crate::members::{get_member, store_member};
use crate::server::AppState;
pub const JOIN_ACCEPT_DOMAIN_TAG: &[u8] = b"vtc-join-accept/v1\0";
const RECIPROCAL_VC_TYPE: &str = "MembershipAcknowledgement";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptRequestBody {
pub member_did: String,
pub vmc_id: String,
pub vc: JsonValue,
pub signature: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptResponse {
pub request_id: Uuid,
pub status: String,
pub reciprocal_vc_id: String,
}
pub struct AcceptOutcome {
pub request_id: Uuid,
pub reciprocal_vc_id: String,
pub recorded: bool,
}
pub async fn accept(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<AcceptRequestBody>,
) -> Result<(StatusCode, Json<AcceptResponse>), AppError> {
let outcome = accept_inner(
&state,
id,
body.member_did,
body.vmc_id,
body.vc,
Some(&body.signature),
JoinTransport::Rest,
)
.await?;
Ok((
StatusCode::OK,
Json(AcceptResponse {
request_id: outcome.request_id,
status: "accepted".to_string(),
reciprocal_vc_id: outcome.reciprocal_vc_id,
}),
))
}
pub async fn accept_inner(
state: &AppState,
id: Uuid,
member_did: String,
vmc_id: String,
vc: JsonValue,
signature_hex: Option<&str>,
transport: JoinTransport,
) -> Result<AcceptOutcome, AppError> {
if let Some(hex_sig) = signature_hex {
verify_holder_signature(&member_did, &vmc_id, &vc, hex_sig)?;
}
let req = get_join_request(&state.join_requests_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("join request not found: {id}")))?;
if req.status != JoinStatus::Approved {
return Err(AppError::Conflict(format!(
"join request {id} is {:?}; only an Approved request has a VMC to reciprocate",
req.status
)));
}
if req.applicant_did != member_did {
return Err(AppError::Validation(format!(
"memberDid does not match the join request applicant ({})",
req.applicant_did
)));
}
let mut member = get_member(&state.members_ks, &member_did)
.await?
.filter(|m| !m.is_removed())
.ok_or_else(|| AppError::NotFound(format!("no active member: {member_did}")))?;
if member.current_vmc_id.as_deref() != Some(vmc_id.as_str()) {
return Err(AppError::Conflict(format!(
"vmcId does not match the member's current VMC ({:?})",
member.current_vmc_id
)));
}
let community_did = state
.config
.read()
.await
.vtc_did
.clone()
.filter(|d| !d.is_empty())
.ok_or_else(|| {
AppError::Internal("VTC DID not configured — cannot bind a reciprocal VC".into())
})?;
let reciprocal_vc_id = verify_reciprocal_vc(&vc, &member_did, &vmc_id, &community_did)?;
if let Some(existing) = member.reciprocal_vc_id.as_deref() {
if existing == reciprocal_vc_id {
return Ok(AcceptOutcome {
request_id: id,
reciprocal_vc_id,
recorded: false,
});
}
return Err(AppError::Conflict(format!(
"membership already reciprocated with a different VC ({existing})"
)));
}
member.record_reciprocation(reciprocal_vc_id.clone());
store_member(&state.members_ks, &member).await?;
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
audit_writer
.write(
&member_did,
Some(&member_did),
AuditEvent::MembershipReciprocated(MembershipReciprocatedData {
request_id: id.to_string(),
vmc_id: vmc_id.clone(),
reciprocal_vc_id: reciprocal_vc_id.clone(),
}),
)
.await?;
info!(
request_id = %id,
member = %member_did,
vmc_id = %vmc_id,
reciprocal_vc_id = %reciprocal_vc_id,
transport = transport.as_str(),
"membership reciprocated"
);
Ok(AcceptOutcome {
request_id: id,
reciprocal_vc_id,
recorded: true,
})
}
fn verify_reciprocal_vc(
vc: &JsonValue,
member_did: &str,
vmc_id: &str,
community_did: &str,
) -> Result<String, AppError> {
let obj = vc
.as_object()
.ok_or_else(|| AppError::Validation("reciprocal vc is not a JSON object".into()))?;
let issuer = match obj.get("issuer") {
Some(JsonValue::String(s)) => s.clone(),
Some(JsonValue::Object(o)) => o
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string(),
_ => String::new(),
};
if issuer != member_did {
return Err(AppError::Validation(format!(
"reciprocal vc issuer `{issuer}` is not the member `{member_did}`"
)));
}
let has_type = obj
.get("type")
.and_then(JsonValue::as_array)
.is_some_and(|a| {
a.iter()
.filter_map(JsonValue::as_str)
.any(|t| t == RECIPROCAL_VC_TYPE)
});
if !has_type {
return Err(AppError::Validation(format!(
"reciprocal vc `type` must include `{RECIPROCAL_VC_TYPE}`"
)));
}
let subject = obj.get("credentialSubject").and_then(JsonValue::as_object);
let subject_id = subject
.and_then(|s| s.get("id"))
.and_then(JsonValue::as_str)
.unwrap_or_default();
if subject_id != community_did {
return Err(AppError::Validation(format!(
"reciprocal vc subject `{subject_id}` is not this community `{community_did}`"
)));
}
let reciprocates = subject
.and_then(|s| s.get("reciprocates"))
.and_then(JsonValue::as_str)
.unwrap_or_default();
if reciprocates != vmc_id {
return Err(AppError::Validation(format!(
"reciprocal vc `reciprocates` ({reciprocates}) does not name the member's VMC ({vmc_id})"
)));
}
let proof_val = obj
.get("proof")
.ok_or_else(|| AppError::Validation("reciprocal vc has no issuer `proof`".into()))?;
let proof: DataIntegrityProof = serde_json::from_value(proof_val.clone()).map_err(|e| {
AppError::Validation(format!("reciprocal vc proof is not Data-Integrity: {e}"))
})?;
if proof.proof_purpose != "assertionMethod" {
return Err(AppError::Validation(format!(
"reciprocal vc proof purpose is `{}`, expected `assertionMethod`",
proof.proof_purpose
)));
}
if proof
.verification_method
.split('#')
.next()
.unwrap_or_default()
!= member_did
{
return Err(AppError::Validation(format!(
"reciprocal vc proof verificationMethod `{}` is not under the member `{member_did}`",
proof.verification_method
)));
}
let pub_bytes = affinidi_crypto::did_key::did_key_to_ed25519_pub(member_did)
.map_err(|e| AppError::Validation(format!("member_did is not a parseable did:key: {e}")))?;
let mut unsigned = vc.clone();
if let Some(o) = unsigned.as_object_mut() {
o.remove("proof");
}
proof
.verify_with_public_key(&unsigned, &pub_bytes, VerifyOptions::new())
.map_err(|e| {
AppError::Validation(format!("reciprocal vc issuer proof did not verify: {e}"))
})?;
obj.get("id")
.and_then(JsonValue::as_str)
.map(str::to_string)
.ok_or_else(|| AppError::Validation("reciprocal vc is missing a top-level `id`".into()))
}
fn verify_holder_signature(
member_did: &str,
vmc_id: &str,
vc: &JsonValue,
signature_hex: &str,
) -> Result<(), AppError> {
let pubkey_bytes = affinidi_crypto::did_key::did_key_to_ed25519_pub(member_did)
.map_err(|e| AppError::Validation(format!("member_did is not a parseable did:key: {e}")))?;
let verifying = VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|e| AppError::Validation(format!("member_did decodes to an invalid key: {e}")))?;
let payload = canonical_payload(member_did, vmc_id, vc)?;
let signing_bytes = signing_bytes(&payload);
let raw_sig = hex::decode(signature_hex)
.map_err(|e| AppError::Validation(format!("signature is not hex: {e}")))?;
let signature = Signature::from_slice(&raw_sig).map_err(|e| {
AppError::Validation(format!("signature is not a 64-byte Ed25519 value: {e}"))
})?;
verifying
.verify(&signing_bytes, &signature)
.map_err(|e| AppError::Validation(format!("holder-binding signature failed: {e}")))?;
Ok(())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CanonicalPayload<'a> {
member_did: &'a str,
vmc_id: &'a str,
vc: &'a JsonValue,
}
fn canonical_payload(member_did: &str, vmc_id: &str, vc: &JsonValue) -> Result<Vec<u8>, AppError> {
serde_json::to_vec(&CanonicalPayload {
member_did,
vmc_id,
vc,
})
.map_err(|e| AppError::Internal(format!("canonical payload serialize: {e}")))
}
fn signing_bytes(payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(JOIN_ACCEPT_DOMAIN_TAG.len() + payload.len());
buf.extend_from_slice(JOIN_ACCEPT_DOMAIN_TAG);
buf.extend_from_slice(payload);
buf
}