use affinidi_vc::{CredentialBuilder, VerifiableCredential};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as JsonValue, json};
use vti_common::error::AppError;
use super::LocalSigner;
use super::VMC_CONTEXT_URL;
pub const VMC_TYPE: &str = "VerifiableMembershipCredential";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialStatusRef {
pub id: String,
#[serde(rename = "type")]
pub r#type: String,
pub status_purpose: String,
pub status_list_index: String,
pub status_list_credential: String,
}
impl CredentialStatusRef {
pub fn revocation(status_list_url: impl Into<String>, index: u32) -> Self {
let url = status_list_url.into();
Self {
id: format!("{url}#{index}"),
r#type: "BitstringStatusListEntry".into(),
status_purpose: "revocation".into(),
status_list_index: index.to_string(),
status_list_credential: url,
}
}
}
#[derive(Debug, Clone)]
pub struct VmcParams {
pub member_did: String,
pub id: Option<String>,
pub status_ref: Option<CredentialStatusRef>,
pub validity: Duration,
pub personhood: bool,
}
impl VmcParams {
pub fn new(member_did: impl Into<String>) -> Self {
Self {
member_did: member_did.into(),
id: None,
status_ref: None,
validity: super::DEFAULT_VMC_VALIDITY,
personhood: false,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn with_status_ref(mut self, status_ref: CredentialStatusRef) -> Self {
self.status_ref = Some(status_ref);
self
}
pub fn with_validity(mut self, validity: Duration) -> Self {
self.validity = validity;
self
}
pub fn with_personhood(mut self, personhood: bool) -> Self {
self.personhood = personhood;
self
}
}
pub async fn build_vmc(
signer: &LocalSigner,
params: VmcParams,
) -> Result<VerifiableCredential, AppError> {
let now = Utc::now();
let valid_until = now + params.validity;
let mut subject = Map::new();
subject.insert("id".into(), JsonValue::String(params.member_did.clone()));
subject.insert("personhood".into(), JsonValue::Bool(params.personhood));
let mut vc = CredentialBuilder::v2()
.context(VMC_CONTEXT_URL)
.issuer_uri(signer.issuer_did().to_string())
.add_type(VMC_TYPE)
.valid_from(rfc3339(now))
.valid_until(rfc3339(valid_until))
.subject(subject)
.build()
.map_err(|e| AppError::Internal(format!("VMC build: {e}")))?;
if let Some(id) = ¶ms.id {
attach_top_level_field(&mut vc, "id", JsonValue::String(id.clone()))?;
}
if let Some(status_ref) = ¶ms.status_ref {
attach_credential_status(&mut vc, status_ref)?;
}
signer.sign(&mut vc).await?;
Ok(vc)
}
fn attach_credential_status(
vc: &mut VerifiableCredential,
status_ref: &CredentialStatusRef,
) -> Result<(), AppError> {
let status = serde_json::to_value(status_ref)
.map_err(|e| AppError::Internal(format!("credentialStatus -> value: {e}")))?;
attach_top_level_field(vc, "credentialStatus", status)
}
fn attach_top_level_field(
vc: &mut VerifiableCredential,
key: &str,
value: JsonValue,
) -> Result<(), AppError> {
let mut as_value =
serde_json::to_value(&*vc).map_err(|e| AppError::Internal(format!("VMC -> value: {e}")))?;
as_value
.as_object_mut()
.ok_or_else(|| AppError::Internal("VMC not an object".into()))?
.insert(key.to_string(), value);
*vc = serde_json::from_value(as_value)
.map_err(|e| AppError::Internal(format!("value -> VMC: {e}")))?;
Ok(())
}
fn rfc3339(t: chrono::DateTime<Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[allow(dead_code)]
fn vmc_subject_template() -> JsonValue {
json!({ "id": "", "personhood": false })
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_VTC_DID: &str = "did:webvh:vtc.example.com:abc";
const MEMBER_DID: &str = "did:key:zMember1";
fn signer() -> LocalSigner {
LocalSigner::from_ed25519_seed(TEST_VTC_DID.into(), &[0xAA; 32])
}
#[tokio::test]
async fn vmc_happy_path_verifies() {
let signer = signer();
let vc = build_vmc(&signer, VmcParams::new(MEMBER_DID))
.await
.expect("build VMC");
assert!(vc.types.iter().any(|t| t == "VerifiableCredential"));
assert!(vc.types.iter().any(|t| t == VMC_TYPE));
let issuer_value = serde_json::to_value(&vc.issuer).unwrap();
assert_eq!(issuer_value, JsonValue::String(TEST_VTC_DID.into()));
let subject_id = match &vc.credential_subject {
affinidi_vc::SubjectValue::Single(m) => m.get("id").cloned(),
affinidi_vc::SubjectValue::Multiple(v) => v[0].get("id").cloned(),
};
assert_eq!(subject_id, Some(JsonValue::String(MEMBER_DID.into())));
signer.verify(&vc).expect("VMC proof must verify");
}
#[tokio::test]
async fn vmc_valid_until_pinned_to_validity_window() {
let signer = signer();
let validity = Duration::days(7);
let vc = build_vmc(&signer, VmcParams::new(MEMBER_DID).with_validity(validity))
.await
.unwrap();
let vf = chrono::DateTime::parse_from_rfc3339(vc.valid_from.as_deref().unwrap()).unwrap();
let vu = chrono::DateTime::parse_from_rfc3339(vc.valid_until.as_deref().unwrap()).unwrap();
assert_eq!((vu - vf).num_seconds(), validity.num_seconds());
}
#[tokio::test]
async fn vmc_personhood_flag_set_in_credential_subject() {
let signer = signer();
let vc = build_vmc(&signer, VmcParams::new(MEMBER_DID).with_personhood(true))
.await
.unwrap();
let subject = match &vc.credential_subject {
affinidi_vc::SubjectValue::Single(m) => m.clone(),
affinidi_vc::SubjectValue::Multiple(v) => v[0].clone(),
};
assert_eq!(subject.get("personhood"), Some(&JsonValue::Bool(true)));
}
#[tokio::test]
async fn vmc_status_ref_serialises_into_credential_status() {
let signer = signer();
let status = CredentialStatusRef::revocation(
"https://vtc.example.com/v1/status-lists/revocation",
7,
);
let vc = build_vmc(
&signer,
VmcParams::new(MEMBER_DID).with_status_ref(status.clone()),
)
.await
.unwrap();
let v = serde_json::to_value(&vc).unwrap();
let cs = &v["credentialStatus"];
assert_eq!(cs["statusPurpose"], "revocation");
assert_eq!(cs["statusListIndex"], "7");
assert_eq!(cs["statusListCredential"], status.status_list_credential);
signer.verify(&vc).expect("VMC proof must still verify");
}
#[tokio::test]
async fn vmc_tampered_subject_invalidates_proof() {
let signer = signer();
let mut vc = build_vmc(&signer, VmcParams::new(MEMBER_DID))
.await
.unwrap();
let mut as_value = serde_json::to_value(&vc).unwrap();
as_value["credentialSubject"]["personhood"] = JsonValue::Bool(true);
vc = serde_json::from_value(as_value).unwrap();
let err = signer.verify(&vc).expect_err("tampered VMC must fail");
assert!(
matches!(err, AppError::Forbidden(_)),
"expected Forbidden, got {err:?}"
);
}
}