use std::sync::Arc;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use affinidi_tdk::{
didcomm::Message,
messaging::{ATM, profiles::ATMProfile},
};
use chrono::{DateTime, Utc};
use serde_json::Value;
use uuid::Uuid;
use vta_sdk::protocols::join_requests::{
JOIN_REQUEST_STATUS_TYPE, JoinRequestStatusBody, JoinRequestSubmitBody,
MEMBER_SELF_REMOVE_TYPE, SelfRemoveBody,
};
use crate::capabilities::TRUST_TASK_ENVELOPE_TYPE;
use crate::errors::OpenVTCError;
pub const MAX_JOIN_SUBMIT_BYTES: usize = 1_048_576;
pub const COMMUNITY_PROFILE_SHOW_TYPE: &str = <trust_tasks_rs::specs::vtc::community::profile::show::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE: &str = <trust_tasks_rs::specs::vtc::community::profile::show::v0_1::Response as trust_tasks_rs::Payload>::TYPE_URI;
pub struct Applicant<'a> {
pub atm: &'a ATM,
pub profile: &'a Arc<ATMProfile>,
pub persona_did: &'a str,
pub signer: &'a Secret,
pub vtc_did: &'a str,
pub mediator_did: &'a str,
pub tsp_mediator_did: Option<&'a str>,
}
pub async fn submit_join_request(
route: &Applicant<'_>,
presentation: impl Into<JoinPresentation>,
) -> Result<Uuid, OpenVTCError> {
let Applicant {
atm,
profile,
persona_did,
signer,
vtc_did,
mediator_did,
tsp_mediator_did,
} = *route;
let request_id = Uuid::new_v4();
let document_id = format!("urn:uuid:{request_id}");
let body = build_join_submit_document(persona_did, signer, vtc_did, presentation, &document_id)
.await?;
if let Some(body_size) = oversized_join_submit(&body) {
return Err(OpenVTCError::InvalidMessage(format!(
"join request is too large to send reliably ({body_size} bytes; limit \
{MAX_JOIN_SUBMIT_BYTES}). A large presentation or invitation credential is the \
usual cause — remove or shrink it and try again."
)));
}
match tsp_mediator_did {
Some(tsp_mediator) => {
crate::tsp::send_trust_task(atm, profile, &body, vtc_did, tsp_mediator).await?;
}
None => {
let now = Utc::now().timestamp().max(0) as u64;
let msg = Message::build(document_id, TRUST_TASK_ENVELOPE_TYPE.to_string(), body)
.from(persona_did.to_string())
.to(vtc_did.to_string())
.created_time(now)
.finalize();
crate::pack_and_send(atm, profile, &msg, persona_did, vtc_did, mediator_did).await?;
}
}
Ok(request_id)
}
fn oversized_join_submit(body: &Value) -> Option<usize> {
let size = serde_json::to_string(body).map(|s| s.len()).unwrap_or(0);
(size > MAX_JOIN_SUBMIT_BYTES).then_some(size)
}
pub async fn poll_join_status(
route: &Applicant<'_>,
request_id: Option<Uuid>,
resend_credentials: bool,
) -> Result<(), OpenVTCError> {
let Applicant {
atm,
profile,
persona_did,
signer,
vtc_did,
mediator_did,
tsp_mediator_did,
} = *route;
let document_id = format!("urn:uuid:{}", Uuid::new_v4());
let payload = JoinRequestStatusBody {
request_id,
resend_credentials,
};
let body = crate::trust_task_doc::build_signed_value(
JOIN_REQUEST_STATUS_TYPE,
persona_did,
vtc_did,
&document_id,
payload,
signer,
)
.await?;
match tsp_mediator_did {
Some(tsp_mediator) => {
crate::tsp::send_trust_task(atm, profile, &body, vtc_did, tsp_mediator).await?;
}
None => {
let now = Utc::now().timestamp().max(0) as u64;
let msg = Message::build(document_id, TRUST_TASK_ENVELOPE_TYPE.to_string(), body)
.from(persona_did.to_string())
.to(vtc_did.to_string())
.created_time(now)
.finalize();
crate::pack_and_send(atm, profile, &msg, persona_did, vtc_did, mediator_did).await?;
}
}
Ok(())
}
static PROFILE_QUERIES: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>,
> = std::sync::LazyLock::new(Default::default);
const PROFILE_QUERY_TTL: std::time::Duration = std::time::Duration::from_secs(600);
const MAX_PROFILE_QUERIES: usize = 256;
fn remember_profile_query(document_id: &str, vtc_did: &str) {
if let Ok(mut q) = PROFILE_QUERIES.lock() {
q.retain(|_, (_, at)| at.elapsed() < PROFILE_QUERY_TTL);
if q.len() < MAX_PROFILE_QUERIES {
q.insert(
document_id.to_string(),
(vtc_did.to_string(), std::time::Instant::now()),
);
}
}
}
#[must_use]
pub fn take_profile_query(vtc_did: &str, thid: &str) -> bool {
let Ok(mut q) = PROFILE_QUERIES.lock() else {
return false;
};
match q.get(thid) {
Some((vtc, at)) if vtc == vtc_did && at.elapsed() < PROFILE_QUERY_TTL => {
q.remove(thid);
true
}
_ => false,
}
}
pub async fn send_community_profile_show(
atm: &ATM,
profile: &Arc<ATMProfile>,
persona_did: &str,
signer: &Secret,
vtc_did: &str,
mediator_did: &str,
tsp_mediator_did: Option<&str>,
) -> Result<(), OpenVTCError> {
let document_id = format!("urn:uuid:{}", Uuid::new_v4());
remember_profile_query(&document_id, vtc_did);
let body = crate::trust_task_doc::build_signed_value(
COMMUNITY_PROFILE_SHOW_TYPE,
persona_did,
vtc_did,
&document_id,
serde_json::json!({}),
signer,
)
.await?;
match tsp_mediator_did {
Some(tsp_mediator) => {
crate::tsp::send_trust_task(atm, profile, &body, vtc_did, tsp_mediator).await?;
}
None => {
let now = Utc::now().timestamp().max(0) as u64;
let msg = Message::build(document_id, TRUST_TASK_ENVELOPE_TYPE.to_string(), body)
.from(persona_did.to_string())
.to(vtc_did.to_string())
.created_time(now)
.finalize();
crate::pack_and_send(atm, profile, &msg, persona_did, vtc_did, mediator_did).await?;
}
}
Ok(())
}
async fn build_join_submit_document(
persona_did: &str,
signer: &Secret,
vtc_did: &str,
presentation: impl Into<JoinPresentation>,
document_id: &str,
) -> Result<Value, OpenVTCError> {
let JoinPresentation {
vp,
extensions,
attributes,
protocol,
criterion,
} = presentation.into();
let body = JoinRequestSubmitBody {
vp,
registry_consent: false,
extensions,
attributes,
};
let mut payload = serde_json::to_value(body)
.map_err(|e| OpenVTCError::Config(format!("join submit body serialize: {e}")))?;
if protocol == crate::vetting::protocol::JoinProtocol::V0_3
&& let (Some(criterion), Some(members)) = (criterion, payload.as_object_mut())
{
members.insert("criterion".to_string(), Value::String(criterion));
}
crate::trust_task_doc::build_signed_value(
protocol.submit_type(),
persona_did,
vtc_did,
document_id,
payload,
signer,
)
.await
}
pub async fn submit_self_remove(
route: &crate::members::Delivery<'_>,
signer: &Secret,
disposition: Option<String>,
) -> Result<Uuid, OpenVTCError> {
let crate::members::Delivery {
member_did,
vtc_did,
..
} = *route;
let msg_id = Uuid::new_v4();
let document_id = format!("urn:uuid:{msg_id}");
let body = crate::trust_task_doc::build_signed_value(
MEMBER_SELF_REMOVE_TYPE,
member_did,
vtc_did,
&document_id,
SelfRemoveBody { disposition },
signer,
)
.await?;
crate::members::send_document(route, document_id, body).await?;
Ok(msg_id)
}
pub fn build_join_vp(
holder_did: &str,
invitation: Option<&Value>,
linkage: Option<&SubjectLinkage>,
) -> Value {
let mut vp = serde_json::json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": "VerifiablePresentation",
"holder": holder_did,
});
if let Some(vic) = invitation {
vp["verifiableCredential"] = Value::Array(vec![vic.clone()]);
}
if let Some(l) = linkage {
vp["subjectLinkage"] = serde_json::json!({
"verificationMethod": l.verification_method,
"signature": l.signature_hex,
});
}
vp
}
#[derive(Debug, Clone, PartialEq)]
pub struct JoinPresentation {
pub vp: Value,
pub extensions: Value,
pub attributes: Vec<vta_sdk::protocols::join_requests::JoinRequestAttribute>,
pub protocol: crate::vetting::protocol::JoinProtocol,
pub criterion: Option<String>,
}
impl From<Value> for JoinPresentation {
fn from(vp: Value) -> Self {
Self {
vp,
extensions: Value::Null,
attributes: Vec::new(),
protocol: Default::default(),
criterion: None,
}
}
}
pub fn attach_credentials(vp: &mut Value, credentials: impl IntoIterator<Item = Value>) {
let mut credentials = credentials.into_iter().peekable();
if credentials.peek().is_none() {
return;
}
let list = vp
.as_object_mut()
.expect("a VP is an object")
.entry("verifiableCredential")
.or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(list) = list {
list.extend(credentials);
}
}
pub const SUBJECT_LINKAGE_DOMAIN_TAG: &[u8] = b"vtc-invitation-subject-linkage/v1\0";
#[derive(Debug, Clone)]
pub struct SubjectLinkage {
pub verification_method: String,
pub signature_hex: String,
}
pub fn subject_linkage_signing_bytes(vic_id: &str, presenter_did: &str) -> Vec<u8> {
let mut bytes = SUBJECT_LINKAGE_DOMAIN_TAG.to_vec();
bytes.extend_from_slice(vic_id.as_bytes());
bytes.push(0);
bytes.extend_from_slice(presenter_did.as_bytes());
bytes
}
pub fn sign_subject_linkage(
private_seed: &[u8; 32],
verification_method: impl Into<String>,
vic_id: &str,
presenter_did: &str,
) -> Result<SubjectLinkage, OpenVTCError> {
let bytes = subject_linkage_signing_bytes(vic_id, presenter_did);
let signature = affinidi_tdk::affinidi_crypto::jose::signing::sign(&bytes, private_seed)
.map_err(|e| OpenVTCError::Config(format!("subject-linkage signing failed: {e}")))?;
Ok(SubjectLinkage {
verification_method: verification_method.into(),
signature_hex: hex::encode(signature),
})
}
pub fn invitation_subject(vic: &Value) -> Option<&str> {
vic.pointer("/credentialSubject/id").and_then(Value::as_str)
}
pub fn invitation_id(vic: &Value) -> Option<&str> {
vic.get("id").and_then(Value::as_str)
}
pub fn invitation_valid_from(vic: &Value) -> Option<&str> {
vic.get("validFrom").and_then(Value::as_str)
}
pub fn invitation_valid_until(vic: &Value) -> Option<&str> {
vic.get("validUntil").and_then(Value::as_str)
}
pub fn invitation_issuer(vic: &Value) -> Option<&str> {
match vic.get("issuer")? {
Value::String(s) => Some(s.as_str()),
Value::Object(_) => vic.pointer("/issuer/id").and_then(Value::as_str),
_ => None,
}
}
pub fn invitation_matches_community(vic: &Value, vtc_did: &str) -> bool {
invitation_issuer(vic) == Some(vtc_did)
}
pub fn invitation_is_expired(vic: &Value, now: DateTime<Utc>) -> bool {
match vic.get("validUntil").and_then(Value::as_str) {
None => false,
Some(s) => match DateTime::parse_from_rfc3339(s) {
Ok(t) => t.with_timezone(&Utc) <= now,
Err(_) => true,
},
}
}
pub use dtg_credentials::{DTG_CONTEXT_V1 as DTG_CONTEXT, W3C_VC_V2_CONTEXT};
pub const DTG_BASE_TYPE: &str = "DTGCredential";
pub const INVITATION_ISSUER_SCOPE: dtg_credentials::IssuerScope =
dtg_credentials::IssuerScope::Public;
pub fn is_invitation_credential(value: &Value) -> bool {
value
.get("type")
.and_then(|t| t.as_array())
.is_some_and(|types| {
types
.iter()
.any(|t| t.as_str() == Some("InvitationCredential"))
})
}
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> {
let mut missing: Vec<&str> = Vec::new();
let ctx = vic.get("@context").and_then(Value::as_array);
match ctx {
Some(c) if c.first().and_then(Value::as_str) == Some(W3C_VC_V2_CONTEXT) => {}
_ => missing.push(
"@context (must be an array whose first item is \"https://www.w3.org/ns/credentials/v2\")",
),
}
if !ctx.is_some_and(|c| c.get(1).and_then(Value::as_str) == Some(DTG_CONTEXT)) {
missing.push(
"@context second item \"https://registry.trustoverip.org/dtg/context/v1\" \
(an invitation under an older DTG context must be re-issued)",
);
}
if vic.get("issuerScope").and_then(Value::as_str) != Some(INVITATION_ISSUER_SCOPE.as_str()) {
missing.push("issuerScope (a community's invitation declares \"public\")");
}
if !is_invitation_credential(vic) {
missing
.push("type (array containing \"VerifiableCredential\" and \"InvitationCredential\")");
} else if !vic
.get("type")
.and_then(Value::as_array)
.is_some_and(|t| t.iter().any(|v| v.as_str() == Some("VerifiableCredential")))
{
missing.push("type entry \"VerifiableCredential\"");
}
if !vic
.get("type")
.and_then(Value::as_array)
.is_some_and(|t| t.iter().any(|v| v.as_str() == Some(DTG_BASE_TYPE)))
{
missing.push("type entry \"DTGCredential\"");
}
if invitation_issuer(vic).is_none() {
missing.push("issuer (a DID string or an object with an `id`)");
}
if invitation_subject(vic).is_none() {
missing.push("credentialSubject.id");
}
if invitation_id(vic).is_none() {
missing.push("id (top-level, the single-use consumption handle)");
}
if vic.get("validUntil").and_then(Value::as_str).is_none() {
missing.push("validUntil (RFC3339 expiry)");
}
if vic.get("credentialStatus").is_none() {
missing.push("credentialStatus (revocation handle)");
}
if vic.get("proof").is_none() {
missing.push("proof (the issuer's Data-Integrity signature)");
}
if missing.is_empty() {
let parsed = crate::dtg::parse_conformant(vic)
.map_err(|e| format!("not a conformant DTG Invitation Credential: {e}"))?;
if parsed.type_() != dtg_credentials::DTGCredentialType::Invitation {
return Err("not an Invitation Credential".to_string());
}
Ok(())
} else {
Err(format!(
"not a complete Invitation Credential — missing or malformed: {}. \
Paste the full signed credential the community issued (the copy/QR \
payload), not a summary view.",
missing.join("; ")
))
}
}
pub async fn verify_invitation_credential(
vic: &Value,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
now: chrono::DateTime<chrono::Utc>,
) -> Result<(), String> {
validate_invitation_credential(vic)?;
let issuer = invitation_issuer(vic)
.ok_or_else(|| "the invitation names no issuer".to_string())?
.to_string();
crate::issued_credential::verify_issued_credential(vic.clone(), &issuer, resolver, now)
.await
.map(|_| ())
.map_err(|e| format!("the invitation did not verify: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn oversized_join_submit_is_flagged() {
let (applicant, signer) =
affinidi_tdk::dids::DID::generate_did_key(affinidi_tdk::dids::KeyType::Ed25519)
.expect("did:key generates");
let ordinary = build_join_submit_document(
&applicant,
&signer,
"did:webvh:example.com:vtc",
json!({}),
"urn:uuid:1",
)
.await
.expect("builds");
assert_eq!(oversized_join_submit(&ordinary), None);
let huge = json!({ "vp": "x".repeat(MAX_JOIN_SUBMIT_BYTES + 1) });
let body = build_join_submit_document(
&applicant,
&signer,
"did:webvh:example.com:vtc",
huge,
"urn:uuid:2",
)
.await
.expect("builds");
assert!(
oversized_join_submit(&body).is_some_and(|n| n > MAX_JOIN_SUBMIT_BYTES),
"an oversized submit must be flagged"
);
}
#[test]
fn a_status_poll_is_a_well_formed_trust_task_document() {
let request_id = Uuid::new_v4();
let doc = crate::trust_task_doc::build_value(
JOIN_REQUEST_STATUS_TYPE,
"did:webvh:example.com:alice",
"did:webvh:example.com:community",
"urn:uuid:doc-1",
JoinRequestStatusBody {
request_id: Some(request_id),
resend_credentials: false,
},
)
.expect("the status document builds");
assert_eq!(doc["id"], json!("urn:uuid:doc-1"));
assert_eq!(doc["type"], json!(JOIN_REQUEST_STATUS_TYPE));
assert_eq!(doc["issuer"], json!("did:webvh:example.com:alice"));
assert_eq!(doc["recipient"], json!("did:webvh:example.com:community"));
assert_eq!(
doc["payload"]["requestId"],
json!(request_id),
"the community looks the join up by this id"
);
}
#[tokio::test]
async fn the_submit_document_keeps_its_shape_through_the_shared_builder() {
let (applicant, signer) =
affinidi_tdk::dids::DID::generate_did_key(affinidi_tdk::dids::KeyType::Ed25519)
.expect("did:key generates");
let doc = build_join_submit_document(
&applicant,
&signer,
"did:webvh:example.com:community",
json!({ "type": ["VerifiablePresentation"] }),
"urn:uuid:submit-1",
)
.await
.expect("the submit document builds");
assert_eq!(doc["id"], json!("urn:uuid:submit-1"));
assert_eq!(
doc["type"],
json!(crate::vetting::protocol::JoinProtocol::V0_3.submit_type())
);
assert_eq!(doc["issuer"], json!(applicant));
assert_eq!(doc["recipient"], json!("did:webvh:example.com:community"));
assert_eq!(
doc["payload"]["vp"]["type"],
json!(["VerifiablePresentation"])
);
assert_eq!(
doc["proof"]["cryptosuite"],
json!("eddsa-jcs-2022"),
"the submit must be signed: {doc}"
);
}
fn sample_vic() -> Value {
json!({
"id": "urn:uuid:vic-1",
"type": ["VerifiableCredential", "InvitationCredential"],
"issuer": "did:webvh:example.com:community",
"credentialSubject": { "id": "did:webvh:example.com:alice" },
"proof": { "type": "DataIntegrityProof" }
})
}
#[test]
fn is_invitation_credential_checks_the_type_tag() {
assert!(is_invitation_credential(&sample_vic()));
assert!(!is_invitation_credential(&json!({ "id": "x" })));
assert!(!is_invitation_credential(
&json!({ "type": ["VerifiableCredential", "MembershipCredential"] })
));
assert!(!is_invitation_credential(
&json!({ "type": "InvitationCredential" })
));
}
#[test]
fn vp_without_invitation_is_holder_only() {
let vp = build_join_vp("did:webvh:example.com:alice", None, None);
assert_eq!(vp["type"], "VerifiablePresentation");
assert_eq!(vp["holder"], "did:webvh:example.com:alice");
assert!(
vp.get("verifiableCredential").is_none(),
"no invitation → no credentials array"
);
assert!(vp.get("subjectLinkage").is_none());
}
#[test]
fn vp_with_invitation_embeds_the_vic() {
let vic = sample_vic();
let vp = build_join_vp("did:webvh:example.com:alice", Some(&vic), None);
let creds = vp["verifiableCredential"]
.as_array()
.expect("verifiableCredential is an array");
assert_eq!(creds.len(), 1);
assert_eq!(creds[0], vic, "the VIC is embedded verbatim");
assert!(
vp.get("subjectLinkage").is_none(),
"no linkage on the join-as-subject path"
);
}
#[test]
fn vp_with_linkage_embeds_the_proof() {
let vic = sample_vic();
let linkage = SubjectLinkage {
verification_method: "did:webvh:example.com:alice#key-0".into(),
signature_hex: "deadbeef".into(),
};
let vp = build_join_vp("did:key:zFreshB", Some(&vic), Some(&linkage));
assert_eq!(
vp["subjectLinkage"]["verificationMethod"],
"did:webvh:example.com:alice#key-0"
);
assert_eq!(vp["subjectLinkage"]["signature"], "deadbeef");
}
#[tokio::test]
async fn vetting_statements_ride_beside_the_invitation_and_the_digest_in_extensions() {
let vic = sample_vic();
let mut vp = build_join_vp("did:webvh:example.com:alice", Some(&vic), None);
attach_credentials(
&mut vp,
[
json!({ "id": "urn:uuid:s1" }),
json!({ "id": "urn:uuid:s2" }),
],
);
let creds = vp["verifiableCredential"].as_array().unwrap();
assert_eq!(creds.len(), 3);
assert_eq!(creds[0], vic, "the invitation stays first");
let mut bare = build_join_vp("did:webvh:example.com:alice", None, None);
attach_credentials(&mut bare, Vec::new());
assert!(
bare.get("verifiableCredential").is_none(),
"nothing to attach adds nothing"
);
let (applicant, signer) =
affinidi_tdk::dids::DID::generate_did_key(affinidi_tdk::dids::KeyType::Ed25519)
.expect("did:key generates");
let doc = build_join_submit_document(
&applicant,
&signer,
"did:webvh:example.com:community",
JoinPresentation {
vp,
extensions: json!({ "requirementsDigest": "zDigest" }),
attributes: Vec::new(),
protocol: crate::vetting::protocol::JoinProtocol::V0_2,
criterion: None,
},
"urn:uuid:submit-2",
)
.await
.unwrap();
assert_eq!(
doc["payload"]["extensions"]["requirementsDigest"],
"zDigest"
);
}
#[tokio::test]
async fn a_v0_3_submit_names_its_criterion() {
use crate::vetting::protocol::JoinProtocol;
use trust_tasks_rs::specs::vtc::join_requests::submit;
let (applicant, signer) =
affinidi_tdk::dids::DID::generate_did_key(affinidi_tdk::dids::KeyType::Ed25519)
.expect("did:key generates");
let digest = "zQmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG";
let build = |protocol| {
build_join_submit_document(
&applicant,
&signer,
"did:webvh:example.com:community",
JoinPresentation {
vp: json!({ "type": ["VerifiablePresentation"] }),
extensions: Value::Null,
attributes: Vec::new(),
protocol,
criterion: Some(digest.to_string()),
},
"urn:uuid:submit-3",
)
};
let v3 = build(JoinProtocol::V0_3).await.unwrap();
assert_eq!(v3["type"], json!(JoinProtocol::V0_3.submit_type()));
assert_eq!(v3["payload"]["criterion"], digest);
serde_json::from_value::<submit::v0_3::Payload>(v3["payload"].clone())
.expect("the payload is the generated submit/0.3 shape");
let v2 = build(JoinProtocol::V0_2).await.unwrap();
assert_eq!(v2["type"], json!(JoinProtocol::V0_2.submit_type()));
assert!(v2["payload"].get("criterion").is_none());
serde_json::from_value::<submit::v0_2::Payload>(v2["payload"].clone())
.expect("the payload is the generated submit/0.2 shape");
}
#[test]
fn vp_carries_the_w3c_base_context() {
let vp = build_join_vp("did:webvh:example.com:alice", None, None);
assert_eq!(
vp["@context"][0], "https://www.w3.org/ns/credentials/v2",
"VP @context must lead with the W3C v2 base context"
);
}
fn complete_vic() -> Value {
json!({
"@context": [W3C_VC_V2_CONTEXT, DTG_CONTEXT],
"id": "urn:uuid:vic-1",
"type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
"issuer": "did:webvh:example.com:community",
"issuerScope": "public",
"credentialSubject": { "id": "did:webvh:example.com:alice" },
"validFrom": "2026-01-01T00:00:00Z",
"validUntil": "2099-01-01T00:00:00Z",
"credentialStatus": { "type": "BitstringStatusListEntry" },
"proof": { "type": "DataIntegrityProof" }
})
}
#[tokio::test]
async fn an_invitation_must_verify_against_its_issuer() {
let resolver = affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.unwrap();
let key = |seed: u8| {
let mut s = affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(
None,
Some(&[seed; 32]),
);
let mb = s.get_public_keymultibase().unwrap();
let did = format!("did:key:{mb}");
s.id = format!("{did}#{mb}");
(did, s)
};
let (community, community_key) = key(0x61);
let (_, other_key) = key(0x62);
let mut vic = complete_vic();
vic["issuer"] = json!(community);
vic["credentialStatus"] = json!({
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "3",
"statusListCredential": "https://127.0.0.1:1/status",
});
let now = chrono::Utc::now();
let err = verify_invitation_credential(&vic, &resolver, now)
.await
.unwrap_err();
assert!(err.contains("did not verify"), "{err}");
let forged = crate::proof_check::test_support::sign(vic.clone(), &[&other_key]).await;
let err = verify_invitation_credential(&forged, &resolver, now)
.await
.unwrap_err();
assert!(err.contains("does not belong to the signer"), "{err}");
let genuine = crate::proof_check::test_support::sign(vic.clone(), &[&community_key]).await;
let err = verify_invitation_credential(&genuine, &resolver, now)
.await
.unwrap_err();
assert!(err.contains("revocation status"), "{err}");
}
#[test]
fn a_profile_answer_is_taken_once_and_only_from_the_community_asked() {
remember_profile_query("urn:uuid:q1", "did:webvh:vtc");
assert!(!take_profile_query("did:webvh:mallory", "urn:uuid:q1"));
assert!(!take_profile_query("did:webvh:vtc", "urn:uuid:other"));
assert!(take_profile_query("did:webvh:vtc", "urn:uuid:q1"));
assert!(
!take_profile_query("did:webvh:vtc", "urn:uuid:q1"),
"answered once"
);
}
#[test]
fn validate_accepts_a_complete_vic() {
assert!(validate_invitation_credential(&complete_vic()).is_ok());
let mut v = complete_vic();
v["issuer"] = json!({ "id": "did:webvh:example.com:community" });
assert!(validate_invitation_credential(&v).is_err());
}
#[test]
fn validate_rejects_a_vic_missing_the_dtg_common_structure() {
let mut no_ctx = complete_vic();
no_ctx["@context"] = json!(["https://www.w3.org/ns/credentials/v2"]);
let err = validate_invitation_credential(&no_ctx).expect_err("missing DTG context");
assert!(
err.contains(DTG_CONTEXT),
"error should name the missing context: {err}"
);
let mut old_ctx = complete_vic();
old_ctx["@context"][1] = json!(crate::dtg::fixtures::RETIRED_CONTEXT);
assert!(validate_invitation_credential(&old_ctx).is_err());
let mut no_scope = complete_vic();
no_scope.as_object_mut().unwrap().remove("issuerScope");
let err = validate_invitation_credential(&no_scope).expect_err("no issuerScope");
assert!(err.contains("issuerScope"), "{err}");
let mut narrow = complete_vic();
narrow["issuerScope"] = json!("directed");
assert!(validate_invitation_credential(&narrow).is_err());
let mut two = complete_vic();
two["type"] = json!([
"VerifiableCredential",
"DTGCredential",
"InvitationCredential",
"MembershipCredential"
]);
assert!(validate_invitation_credential(&two).is_err());
let mut no_base = complete_vic();
no_base["type"] = json!(["VerifiableCredential", "InvitationCredential"]);
let err = validate_invitation_credential(&no_base).expect_err("missing DTGCredential");
assert!(
err.contains("DTGCredential"),
"error should name the missing base type: {err}"
);
}
#[test]
fn validate_names_every_missing_field_on_a_stripped_vic() {
let stripped = json!({
"type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
"issuer": "did:webvh:example.com:community",
"credentialSubject": { "id": "did:webvh:example.com:alice" },
"validFrom": "2026-06-20T23:11:53Z"
});
let err = validate_invitation_credential(&stripped).expect_err("incomplete");
for needle in ["@context", "id", "validUntil", "credentialStatus", "proof"] {
assert!(err.contains(needle), "error should name `{needle}`: {err}");
}
}
#[test]
fn subject_and_id_extractors() {
let vic = sample_vic();
assert_eq!(
invitation_subject(&vic),
Some("did:webvh:example.com:alice")
);
assert_eq!(invitation_id(&vic), Some("urn:uuid:vic-1"));
assert_eq!(invitation_subject(&json!({})), None);
}
#[tokio::test]
async fn join_submit_body_is_a_trust_task_document_the_vtc_can_parse() {
use trust_tasks_rs::TrustTask;
let (applicant, signer) =
affinidi_tdk::dids::DID::generate_did_key(affinidi_tdk::dids::KeyType::Ed25519)
.expect("did:key generates");
let vp = build_join_vp(&applicant, Some(&sample_vic()), None);
let body = build_join_submit_document(
&applicant,
&signer,
"did:webvh:example.com:community",
vp,
&format!("urn:uuid:{}", Uuid::new_v4()),
)
.await
.expect("build document");
let doc: TrustTask<serde_json::Value> =
serde_json::from_value(body.clone()).expect("body parses as a TrustTask document");
assert!(doc.id.starts_with("urn:uuid:"), "document carries an id");
assert_eq!(
doc.type_uri.to_string(),
crate::vetting::protocol::JoinProtocol::V0_3.submit_type(),
"type URI is the submit type"
);
assert_eq!(doc.issuer.as_deref(), Some(applicant.as_str()));
assert_eq!(
doc.recipient.as_deref(),
Some("did:webvh:example.com:community")
);
assert!(
doc.proof.is_some(),
"the submit carries the proof its specification declares REQUIRED"
);
assert_eq!(doc.payload["vp"]["holder"], applicant);
assert!(doc.payload["vp"]["verifiableCredential"].is_array());
}
#[test]
fn issuer_extractor_handles_string_and_object_forms() {
assert_eq!(
invitation_issuer(&sample_vic()),
Some("did:webvh:example.com:community")
);
let obj = json!({ "issuer": { "id": "did:webvh:example.com:community" } });
assert_eq!(
invitation_issuer(&obj),
Some("did:webvh:example.com:community")
);
assert_eq!(invitation_issuer(&json!({})), None);
assert_eq!(invitation_issuer(&json!({ "issuer": 42 })), None);
}
#[test]
fn community_match_keys_on_the_issuer() {
let vic = sample_vic();
assert!(invitation_matches_community(
&vic,
"did:webvh:example.com:community"
));
assert!(!invitation_matches_community(
&vic,
"did:webvh:example.com:other-community"
));
}
#[test]
fn expiry_uses_valid_until_and_fails_closed() {
let now = "2026-06-21T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
assert!(!invitation_is_expired(&sample_vic(), now));
let future = json!({ "validUntil": "2027-01-01T00:00:00Z" });
assert!(!invitation_is_expired(&future, now));
let past = json!({ "validUntil": "2025-01-01T00:00:00Z" });
assert!(invitation_is_expired(&past, now));
let bad = json!({ "validUntil": "not-a-date" });
assert!(invitation_is_expired(&bad, now));
}
#[test]
fn sign_subject_linkage_verifies_with_the_tdk_routine() {
use affinidi_tdk::affinidi_crypto::jose::signing;
let seed = [7u8; 32];
let pubkey = signing::public_key_from_private(&seed);
let linkage = sign_subject_linkage(
&seed,
"did:webvh:example.com:alice#key-0",
"urn:uuid:vic-1",
"did:key:zFreshB",
)
.expect("sign");
assert_eq!(
linkage.verification_method,
"did:webvh:example.com:alice#key-0"
);
let sig: [u8; 64] = hex::decode(&linkage.signature_hex)
.unwrap()
.try_into()
.unwrap();
let bytes = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zFreshB");
assert!(signing::verify(&bytes, &sig, &pubkey).is_ok());
let other = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zOther");
assert!(signing::verify(&other, &sig, &pubkey).is_err());
}
#[test]
fn linkage_signing_bytes_are_tag_id_nul_presenter() {
let bytes = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zB");
let mut expected = SUBJECT_LINKAGE_DOMAIN_TAG.to_vec();
expected.extend_from_slice(b"urn:uuid:vic-1");
expected.push(0);
expected.extend_from_slice(b"did:key:zB");
assert_eq!(bytes, expected);
}
}