use affinidi_vc::VerifiableCredential;
use chrono::Duration;
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use super::LocalSigner;
pub const VMC_TYPE: &str = "MembershipCredential";
#[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 doc = super::dtg::issue_membership(
signer,
¶ms.member_did,
params.id.as_deref(),
params.status_ref.as_ref(),
params.validity,
params.personhood,
)
.await?;
serde_json::from_value(doc)
.map_err(|e| AppError::Internal(format!("DTG VMC -> VerifiableCredential: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value as JsonValue;
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_adds_personhood_type() {
let signer = signer();
let vc = build_vmc(&signer, VmcParams::new(MEMBER_DID).with_personhood(true))
.await
.unwrap();
assert!(
vc.types.iter().any(|t| t == "PersonhoodCredential"),
"personhood=true must add the PersonhoodCredential type, got {:?}",
vc.types
);
}
#[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:?}"
);
}
}