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,
MemberAddedData,
};
use vti_common::error::AppError;
use crate::ceremony::execute::{self, AdmitOutcome, top_level_id};
use crate::ceremony::{
Credential, CredentialStatus, EffectOutcome, EffectPlan, Evidence, Facts, FactsInputs,
Presentation, Purpose, Verdict, VerifiedFacts, assemble_facts,
};
use crate::credentials::vec::VEC_TYPE;
use crate::credentials::vmc::VMC_TYPE;
use crate::join::{JoinRequest, JoinStatus, JoinTransport, list_join_requests, store_join_request};
use crate::policy::{PolicyPurpose, extract::extract_vp_claims, load_active_compiled};
use crate::server::AppState;
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>>,
}
pub async fn submit_inner(
state: &AppState,
applicant_did: String,
vp: JsonValue,
registry_consent: bool,
extensions: JsonValue,
binding: Option<HolderBinding<'_>>,
transport: JoinTransport,
) -> Result<JoinSubmitOutcome, AppError> {
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
)));
}
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(),
));
}
verify_holder_signature(
&applicant_did,
&vp,
registry_consent,
&extensions,
b.audience,
b.created,
b.signature_hex,
)?;
}
if let Some(existing) = find_open_request(&state.join_requests_ks, &applicant_did).await? {
return Err(AppError::Conflict(format!(
"an open join request already exists for {applicant_did} (id {existing}); \
withdraw or await its decision before resubmitting"
)));
}
let vp_claims = extract_vp_claims(&vp);
let presentation = presentation_from_vp(&applicant_did, &vp);
let verdict = decide_join(state, &applicant_did, presentation).await?;
realize_join_verdict(
state,
&applicant_did,
vp,
vp_claims,
registry_consent,
extensions,
verdict,
transport,
)
.await
}
pub async fn decide_join(
state: &AppState,
applicant_did: &str,
presentation: Presentation,
) -> Result<Verdict, AppError> {
let facts = assemble_join_facts(state, applicant_did, presentation).await?;
let verified = VerifiedFacts::assemble(facts)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Join,
)
.await?;
crate::ceremony::decide(&verified, &policy)
}
#[allow(clippy::too_many_arguments)]
pub async fn realize_join_verdict(
state: &AppState,
applicant_did: &str,
vp: JsonValue,
vp_claims: JsonValue,
registry_consent: bool,
extensions: JsonValue,
verdict: Verdict,
transport: JoinTransport,
) -> 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;
request.extensions = extensions;
let mut admit: Option<Box<AdmitOutcome>> = None;
let rejected = matches!(verdict, Verdict::Deny(_));
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(),
};
if let EffectOutcome::Admitted(creds) =
execute::apply(state, plan, applicant_did).await?
{
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);
}
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(_) => {
request.status = JoinStatus::Rejected;
request.policy_decision = Some(serde_json::to_value(&verdict)?);
}
}
store_join_request(&state.join_requests_ks, &request).await?;
if rejected {
audit_writer
.write(
applicant_did,
None,
AuditEvent::JoinRequestRejected(JoinRequestRejectedData {
request_id: request.id.to_string(),
reason: "policy denied".into(),
}),
)
.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,
) -> 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 {
invitation: None,
presentation: Some(presentation),
request: None,
},
},
)
.await
}
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 = obj
.get("type")
.and_then(|t| match t {
JsonValue::Array(a) => a
.iter()
.filter_map(|x| x.as_str())
.find(|s| *s != "VerifiableCredential")
.map(str::to_string),
JsonValue::String(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_else(|| "VerifiableCredential".to_string());
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,
issuer,
issuer_trusted: false,
status: CredentialStatus::Unknown,
holder_bound: false,
claims: JsonValue::Null,
valid_until: 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}")))
}
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_vec, 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 == VEC_TYPE)
.cloned()
.ok_or_else(|| AppError::Internal("credential carries neither VMC nor VEC 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
}
#[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());
}
}