use affinidi_vc::VerifiableCredential;
use chrono::{DateTime, Duration, Utc};
use dtg_credentials::{DTGCredential, IssuerScope, StatementObject};
use serde_json::Value;
use vti_common::error::AppError;
use crate::acl::VtcRole;
use super::signer::LocalSigner;
use super::vmc::CredentialStatusRef;
pub const ROLE_VAC_MAX_ATTENUATION: u32 = 0;
pub fn into_typed(doc: Value, kind: &str) -> Result<VerifiableCredential, AppError> {
serde_json::from_value(doc)
.map_err(|e| AppError::Internal(format!("DTG {kind} -> VerifiableCredential: {e}")))
}
fn window(validity: Duration) -> (DateTime<Utc>, DateTime<Utc>) {
let now = Utc::now();
(now, now + validity)
}
async fn finalize(
signer: &LocalSigner,
dtg: DTGCredential,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
subject_scopes: &[String],
) -> Result<Value, AppError> {
dtg.validate()
.map_err(|e| AppError::Validation(format!("DTG credential is not conformant: {e}")))?;
let mut doc = serde_json::to_value(dtg.credential())
.map_err(|e| AppError::Internal(format!("DTG credential -> value: {e}")))?;
let obj = doc
.as_object_mut()
.ok_or_else(|| AppError::Internal("DTG credential is not a JSON object".into()))?;
if let Some(id) = id {
obj.insert("id".into(), Value::String(id.to_string()));
}
if let Some(status_ref) = status_ref {
let status = serde_json::to_value(status_ref)
.map_err(|e| AppError::Internal(format!("credentialStatus -> value: {e}")))?;
obj.insert("credentialStatus".into(), status);
}
if !subject_scopes.is_empty()
&& let Some(subject) = obj
.get_mut("credentialSubject")
.and_then(Value::as_object_mut)
{
subject.insert(
"scopes".into(),
Value::Array(subject_scopes.iter().cloned().map(Value::String).collect()),
);
}
signer.sign_doc(&mut doc).await?;
Ok(doc)
}
pub async fn issue_membership(
signer: &LocalSigner,
member_did: &str,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
validity: Duration,
personhood: bool,
) -> Result<Value, AppError> {
let (valid_from, valid_until) = window(validity);
let dtg = DTGCredential::new_vmc(
signer.issuer_did().to_string(),
member_did.to_string(),
valid_from,
Some(valid_until),
personhood,
);
finalize(signer, dtg, id, status_ref, &[]).await
}
pub async fn issue_role(
signer: &LocalSigner,
member_did: &str,
role: &VtcRole,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
validity: Duration,
) -> Result<Value, AppError> {
issue_role_action(
signer,
member_did,
&role.to_string(),
id,
status_ref,
validity,
)
.await
}
pub async fn issue_role_action(
signer: &LocalSigner,
member_did: &str,
role: &str,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
validity: Duration,
) -> Result<Value, AppError> {
let (valid_from, valid_until) = window(validity);
issue_role_action_in(
signer,
member_did,
role,
id,
status_ref,
valid_from,
valid_until,
)
.await
}
pub async fn issue_role_action_in(
signer: &LocalSigner,
member_did: &str,
role: &str,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
) -> Result<Value, AppError> {
let dtg = DTGCredential::new_community_role_vac(
signer.issuer_did().to_string(),
member_did.to_string(),
role,
valid_from,
valid_until,
)
.and_then(|vac| vac.with_max_attenuation(ROLE_VAC_MAX_ATTENUATION))
.map_err(|e| AppError::Validation(format!("role credential: {e}")))?;
finalize(signer, dtg, id, status_ref, &[]).await
}
pub fn role_action_for(role: &VtcRole) -> String {
dtg_credentials::create::role_action(&role.to_string())
}
pub async fn issue_statement(
signer: &LocalSigner,
subject_did: &str,
predicate: &str,
value: Value,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
validity: Duration,
) -> Result<Value, AppError> {
let (valid_from, valid_until) = window(validity);
let dtg = DTGCredential::new_vsc(
signer.issuer_did().to_string(),
IssuerScope::Public,
subject_did.to_string(),
predicate,
StatementObject::Value(value),
valid_from,
Some(valid_until),
)
.map_err(|e| AppError::Validation(format!("statement credential: {e}")))?;
finalize(signer, dtg, id, status_ref, &[]).await
}
pub async fn issue_vetted_statement(
signer: &LocalSigner,
subject_did: &str,
value: Value,
request: &Value,
id: &str,
status_ref: &CredentialStatusRef,
validity: Duration,
) -> Result<Value, AppError> {
let (valid_from, valid_until) = window(validity);
let dtg = DTGCredential::new_vetted_vsc(
signer.issuer_did().to_string(),
IssuerScope::Public,
subject_did.to_string(),
value,
request,
valid_from,
Some(valid_until),
)
.map_err(|e| AppError::Validation(format!("vetted/1 statement: {e}")))?;
finalize(signer, dtg, Some(id), Some(status_ref), &[]).await
}
pub async fn issue_invitation(
signer: &LocalSigner,
subject_did: &str,
id: Option<&str>,
status_ref: Option<&CredentialStatusRef>,
validity: Duration,
subject_scopes: &[String],
) -> Result<Value, AppError> {
let (valid_from, valid_until) = window(validity);
let dtg = DTGCredential::new_vic(
signer.issuer_did().to_string(),
IssuerScope::Public,
subject_did.to_string(),
valid_from,
Some(valid_until),
);
finalize(signer, dtg, id, status_ref, subject_scopes).await
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_data_integrity::{DataIntegrityProof, VerifyOptions};
const TEST_DID: &str = "did:web:acme.example";
fn signer() -> LocalSigner {
LocalSigner::from_ed25519_seed(TEST_DID.into(), &[7u8; 32])
}
fn verify(doc: &Value, signer: &LocalSigner) -> Result<(), String> {
let proof: DataIntegrityProof =
serde_json::from_value(doc.get("proof").cloned().ok_or("no proof")?)
.map_err(|e| e.to_string())?;
let mut unsigned = doc.clone();
unsigned.as_object_mut().unwrap().remove("proof");
proof
.verify_with_public_key(&unsigned, signer.public_bytes(), VerifyOptions::new())
.map_err(|e| e.to_string())
}
#[test]
fn into_typed_maps_malformed_json_to_kind_tagged_internal() {
let err = into_typed(serde_json::json!({ "not": "a credential" }), "VMC")
.expect_err("a non-VC object must not parse as a VerifiableCredential");
match err {
AppError::Internal(msg) => {
assert!(msg.contains("VMC"), "error must name the kind: {msg}");
assert!(msg.contains("VerifiableCredential"), "{msg}");
}
other => panic!("expected Internal, got {other:?}"),
}
}
#[tokio::test]
async fn membership_issues_with_catalog_shape_and_verifies() {
let s = signer();
let doc = issue_membership(
&s,
"did:key:zMember",
Some("urn:uuid:vmc-1"),
None,
Duration::days(30),
false,
)
.await
.expect("issue VMC");
verify(&doc, &s).expect("VMC proof verifies");
assert_eq!(doc["issuer"], TEST_DID);
assert_eq!(doc["id"], "urn:uuid:vmc-1");
assert_eq!(doc["credentialSubject"]["id"], "did:key:zMember");
let types: Vec<String> = serde_json::from_value(doc["type"].clone()).unwrap();
assert!(
types.iter().any(|t| t == "MembershipCredential"),
"{types:?}"
);
assert!(
!types.iter().any(|t| t == "PersonhoodCredential"),
"personhood was false"
);
assert!(
doc.get("credentialStatus").is_none(),
"no status_ref → no block"
);
}
#[tokio::test]
async fn personhood_membership_adds_type() {
let s = signer();
let doc = issue_membership(&s, "did:key:zM", None, None, Duration::days(30), true)
.await
.unwrap();
let types: Vec<String> = serde_json::from_value(doc["type"].clone()).unwrap();
assert!(
types.iter().any(|t| t == "PersonhoodCredential"),
"{types:?}"
);
}
#[tokio::test]
async fn role_vac_carries_the_recognition_authority_shape() {
let s = signer();
let doc = issue_role(
&s,
"did:key:zMember",
&VtcRole::Admin,
Some("urn:uuid:vac-1"),
None,
Duration::days(30),
)
.await
.expect("issue role VAC");
verify(&doc, &s).expect("VAC proof verifies");
assert_eq!(doc["issuerScope"], "public");
let authority = &doc["credentialSubject"]["authority"];
assert_eq!(authority["scope"], TEST_DID);
assert_eq!(
authority["actions"],
serde_json::json!([role_action_for(&VtcRole::Admin)])
);
assert_eq!(authority["maxAttenuation"], ROLE_VAC_MAX_ATTENUATION);
assert!(authority.get("parent").is_none());
}
#[tokio::test]
async fn a_statement_under_a_task_bound_predicate_is_refused() {
let err = issue_statement(
&signer(),
"did:key:zMember",
dtg_credentials::VETTED_V1,
serde_json::json!({ "community": TEST_DID }),
None,
None,
Duration::days(30),
)
.await
.expect_err("vetted/1 cannot be issued without a task citation");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
}
#[tokio::test]
async fn invitation_issues_to_a_non_member_and_verifies() {
let s = signer();
let status = CredentialStatusRef::revocation("urn:uuid:invite-list", 3);
let doc = issue_invitation(
&s,
"did:key:zInvitee",
Some("urn:uuid:vic-1"),
Some(&status),
Duration::days(7),
&[],
)
.await
.expect("issue VIC");
verify(&doc, &s).expect("VIC proof verifies");
assert_eq!(doc["credentialSubject"]["id"], "did:key:zInvitee");
let types: Vec<String> = serde_json::from_value(doc["type"].clone()).unwrap();
assert!(
types.iter().any(|t| t == "InvitationCredential"),
"{types:?}"
);
assert!(
doc.get("credentialStatus").is_some(),
"VIC must be revocable"
);
}
#[tokio::test]
async fn credential_status_is_inside_the_signed_bytes() {
let s = signer();
let status = CredentialStatusRef::revocation("urn:uuid:list-1", 42);
let doc = issue_statement(
&s,
"did:key:zMember",
dtg_credentials::ENDORSES_V1,
serde_json::json!({ "skill": "rust" }),
None,
Some(&status),
Duration::days(30),
)
.await
.expect("issue VSC with status");
verify(&doc, &s).expect("VSC-with-status verifies");
assert!(doc.get("credentialStatus").is_some());
let mut tampered = doc.clone();
tampered.as_object_mut().unwrap().remove("credentialStatus");
assert!(
verify(&tampered, &s).is_err(),
"stripping a signed credentialStatus must invalidate the proof"
);
}
}
#[cfg(test)]
mod catalog_wire_shape {
use super::*;
const TEST_DID: &str = "did:web:acme.example";
const CONTEXT: [&str; 2] = [
dtg_credentials::W3C_VC_V2_CONTEXT,
dtg_credentials::DTG_CONTEXT_V1,
];
fn signer() -> LocalSigner {
LocalSigner::from_ed25519_seed(TEST_DID.into(), &[7u8; 32])
}
fn assert_shape(doc: &Value, expected_types: &[&str], kind: &str) {
assert_scoped_shape(doc, expected_types, "public", kind);
}
fn assert_scoped_shape(doc: &Value, expected_types: &[&str], scope: &str, kind: &str) {
assert_eq!(doc["issuerScope"], scope, "{kind}: issuerScope drifted");
let ctx: Vec<String> = serde_json::from_value(doc["@context"].clone())
.unwrap_or_else(|e| panic!("{kind}: @context is not a string array: {e}"));
assert_eq!(ctx, CONTEXT, "{kind}: @context drifted");
let types: Vec<String> = serde_json::from_value(doc["type"].clone())
.unwrap_or_else(|e| panic!("{kind}: type is not a string array: {e}"));
assert_eq!(types, expected_types, "{kind}: type array drifted");
}
#[tokio::test]
async fn membership_credential_wire_shape() {
let doc = issue_membership(
&signer(),
"did:key:zM",
None,
None,
Duration::days(30),
true,
)
.await
.expect("issue VMC");
assert_shape(
&doc,
&[
"VerifiableCredential",
"DTGCredential",
"MembershipCredential",
"PersonhoodCredential",
],
"VMC",
);
}
#[tokio::test]
async fn membership_without_personhood_drops_only_that_type() {
let doc = issue_membership(
&signer(),
"did:key:zM",
None,
None,
Duration::days(30),
false,
)
.await
.expect("issue VMC");
assert_shape(
&doc,
&[
"VerifiableCredential",
"DTGCredential",
"MembershipCredential",
],
"VMC (no personhood)",
);
}
#[tokio::test]
async fn authority_credential_wire_shape() {
let doc = issue_role(
&signer(),
"did:key:zM",
&VtcRole::Member,
None,
None,
Duration::days(30),
)
.await
.expect("issue role VAC");
assert_shape(
&doc,
&[
"VerifiableCredential",
"DTGCredential",
"AuthorityCredential",
],
"role VAC",
);
assert_eq!(
doc["credentialSubject"]["authority"],
serde_json::json!({
"scope": TEST_DID,
"actions": ["role:member"],
"maxAttenuation": 0,
})
);
}
#[tokio::test]
async fn statement_credential_wire_shape() {
let doc = issue_statement(
&signer(),
"did:key:zM",
dtg_credentials::ENDORSES_V1,
serde_json::json!({ "skill": "rust" }),
None,
None,
Duration::days(30),
)
.await
.expect("issue VSC");
assert_shape(
&doc,
&[
"VerifiableCredential",
"DTGCredential",
"StatementCredential",
],
"VSC",
);
assert_eq!(
doc["credentialSubject"]["predicate"],
dtg_credentials::ENDORSES_V1
);
assert_eq!(
doc["credentialSubject"]["object"],
serde_json::json!({ "value": { "skill": "rust" } })
);
}
#[test]
fn persona_credential_wire_shape() {
let (valid_from, valid_until) = super::window(Duration::days(30));
let dtg = dtg_credentials::DTGCredential::new_vpc(
"did:key:zPersona".into(),
IssuerScope::Directed,
"did:key:zCounterparty".into(),
valid_from,
Some(valid_until),
);
let doc = serde_json::to_value(dtg.credential()).expect("VPC body -> value");
assert_scoped_shape(
&doc,
&["VerifiableCredential", "DTGCredential", "PersonaCredential"],
"directed",
"VPC",
);
assert_eq!(doc["issuer"], "did:key:zPersona");
assert_eq!(doc["credentialSubject"]["id"], "did:key:zCounterparty");
}
#[tokio::test]
async fn invitation_credential_wire_shape() {
let doc = issue_invitation(&signer(), "did:key:zM", None, None, Duration::days(30), &[])
.await
.expect("issue VIC");
assert_shape(
&doc,
&[
"VerifiableCredential",
"DTGCredential",
"InvitationCredential",
],
"VIC",
);
}
}