use std::sync::Arc;
use affinidi_data_integrity::crypto_suites::CryptoSuite;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
use affinidi_tdk::{
didcomm::Message,
messaging::{ATM, profiles::ATMProfile},
secrets_resolver::secrets::Secret,
};
use chrono::Utc;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::errors::OpenVTCError;
pub const PERSONHOOD_CHALLENGE_TYPE: &str =
"https://trusttasks.org/spec/vtc/members/personhood/challenge/0.1";
pub const PERSONHOOD_ASSERT_TYPE: &str =
"https://trusttasks.org/spec/vtc/members/personhood/assert/0.1";
pub const PERSONHOOD_CHALLENGE_RESPONSE_TYPE: &str =
"https://trusttasks.org/spec/vtc/members/personhood/challenge/0.1#response";
pub const PERSONHOOD_ASSERT_RESPONSE_TYPE: &str =
"https://trusttasks.org/spec/vtc/members/personhood/assert/0.1#response";
const VC_V2_CONTEXT_URL: &str = "https://www.w3.org/ns/credentials/v2";
const MATCH_DOMAIN_TAG: &[u8] = b"vtc-personhood-match/v1\0";
const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const CODE_CHARS: usize = 8;
const CODE_BITS: usize = CODE_CHARS * 5;
pub fn match_code(challenge_id: &Uuid) -> String {
let mut hasher = Sha256::new();
hasher.update(MATCH_DOMAIN_TAG);
hasher.update(challenge_id.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(CODE_CHARS + 1);
for (i, bit_offset) in (0..CODE_BITS).step_by(5).enumerate() {
let mut idx = 0u8;
for bit in 0..5 {
let abs = bit_offset + bit;
let byte = digest[abs / 8];
idx = (idx << 1) | ((byte >> (7 - (abs % 8))) & 1);
}
if i == 4 {
out.push('-');
}
out.push(CROCKFORD[idx as usize] as char);
}
out
}
pub fn match_code_from_reply(ext: &Value) -> Option<String> {
ext.get("org.openvtc.match-code")
.and_then(|v| v.as_str())
.map(str::to_owned)
}
pub struct Route<'a> {
pub atm: &'a ATM,
pub profile: &'a Arc<ATMProfile>,
pub member_did: &'a str,
pub vtc_did: &'a str,
pub mediator_did: &'a str,
pub tsp_mediator_did: Option<&'a str>,
}
fn didcomm_request(document_id: String, body: Value, member_did: &str, vtc_did: &str) -> Message {
let now = Utc::now().timestamp().max(0) as u64;
Message::build(
document_id,
crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(),
body,
)
.from(member_did.to_string())
.to(vtc_did.to_string())
.created_time(now)
.finalize()
}
impl Route<'_> {
async fn send(&self, body: Value, document_id: String) -> Result<(), OpenVTCError> {
match self.tsp_mediator_did {
Some(tsp_mediator) => {
crate::tsp::send_trust_task(
self.atm,
self.profile,
&body,
self.vtc_did,
tsp_mediator,
)
.await?;
}
None => {
let msg = didcomm_request(document_id, body, self.member_did, self.vtc_did);
crate::pack_and_send(
self.atm,
self.profile,
&msg,
self.member_did,
self.vtc_did,
self.mediator_did,
)
.await?;
}
}
Ok(())
}
}
pub async fn request_challenge(
route: &Route<'_>,
signer: &Secret,
subject_did: &str,
) -> Result<Uuid, OpenVTCError> {
let request_id = Uuid::new_v4();
let document_id = format!("urn:uuid:{request_id}");
let body = build_challenge_request(
route.member_did,
route.vtc_did,
&document_id,
subject_did,
signer,
)
.await?;
route.send(body, document_id).await?;
Ok(request_id)
}
async fn build_challenge_request(
member_did: &str,
vtc_did: &str,
document_id: &str,
subject_did: &str,
signing_secret: &Secret,
) -> Result<Value, OpenVTCError> {
crate::trust_task_doc::build_signed_value(
PERSONHOOD_CHALLENGE_TYPE,
member_did,
vtc_did,
document_id,
json!({ "did": subject_did }),
signing_secret,
)
.await
}
pub async fn build_presentation(
signing_secret: &Secret,
member_did: &str,
challenge_id: &Uuid,
credentials: Vec<Value>,
) -> Result<Value, OpenVTCError> {
let challenge = challenge_id.to_string();
let vp = json!({
"@context": [VC_V2_CONTEXT_URL],
"type": ["VerifiablePresentation"],
"holder": member_did,
"verifiableCredential": credentials,
"nonce": challenge,
});
let proof = DataIntegrityProof::sign(
&vp,
signing_secret,
SignOptions::new()
.with_proof_purpose("authentication")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| OpenVTCError::Config(format!("sign personhood presentation: {e}")))?;
let mut proof_value = serde_json::to_value(&proof)
.map_err(|e| OpenVTCError::Config(format!("serialize presentation proof: {e}")))?;
proof_value
.as_object_mut()
.ok_or_else(|| OpenVTCError::Config("proof did not serialize to an object".into()))?
.insert("challenge".to_string(), Value::String(challenge));
let mut signed = vp;
signed
.as_object_mut()
.expect("presentation is an object")
.insert("proof".to_string(), proof_value);
Ok(signed)
}
pub async fn assert_personhood(
route: &Route<'_>,
signing_secret: &Secret,
challenge_id: &Uuid,
credentials: Vec<Value>,
) -> Result<Uuid, OpenVTCError> {
let presentation =
build_presentation(signing_secret, route.member_did, challenge_id, credentials).await?;
let request_id = Uuid::new_v4();
let document_id = format!("urn:uuid:{request_id}");
let body = crate::trust_task_doc::build_signed_value(
PERSONHOOD_ASSERT_TYPE,
route.member_did,
route.vtc_did,
&document_id,
json!({ "did": route.member_did, "presentation": presentation }),
signing_secret,
)
.await?;
route.send(body, document_id).await?;
Ok(request_id)
}
fn reply_payload(body: &Value) -> Value {
match body.get("payload") {
Some(payload) => payload.clone(),
None => body.clone(),
}
}
#[derive(Debug, Clone)]
pub struct ChallengeReply {
pub challenge_id: Uuid,
pub expires_at: chrono::DateTime<Utc>,
pub match_code: String,
}
pub fn parse_challenge_reply(body: &Value) -> Result<ChallengeReply, OpenVTCError> {
let payload = reply_payload(body);
let challenge_id = payload
.get("challengeId")
.and_then(|v| v.as_str())
.ok_or_else(|| OpenVTCError::Config("challenge reply has no challengeId".into()))?
.parse::<Uuid>()
.map_err(|e| OpenVTCError::Config(format!("challengeId is not a UUID: {e}")))?;
let expires_at = payload
.get("expiresAt")
.and_then(|v| v.as_str())
.ok_or_else(|| OpenVTCError::Config("challenge reply has no expiresAt".into()))?
.parse::<chrono::DateTime<Utc>>()
.map_err(|e| OpenVTCError::Config(format!("expiresAt is not a timestamp: {e}")))?;
let derived = match_code(&challenge_id);
if let Some(theirs) = payload.get("ext").and_then(match_code_from_reply)
&& theirs != derived
{
return Err(OpenVTCError::Config(format!(
"match code disagreement: the community says {theirs}, this client derives \
{derived} from the same challenge — the two implementations have drifted and \
the spoken confirmation cannot be trusted"
)));
}
Ok(ChallengeReply {
challenge_id,
expires_at,
match_code: derived,
})
}
#[derive(Debug, Clone)]
pub struct AssertReply {
pub did: String,
pub personhood: bool,
pub vmc: Value,
pub role_vac: Value,
}
pub fn parse_assert_reply(body: &Value) -> Result<AssertReply, OpenVTCError> {
let payload = reply_payload(body);
Ok(AssertReply {
did: payload
.get("did")
.and_then(|v| v.as_str())
.ok_or_else(|| OpenVTCError::Config("assert reply has no did".into()))?
.to_string(),
personhood: payload
.get("personhood")
.and_then(|v| v.as_bool())
.unwrap_or(false),
vmc: payload.get("vmc").cloned().unwrap_or(Value::Null),
role_vac: payload.get("roleVac").cloned().unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
#[test]
fn a_personhood_document_rides_the_binding_envelope() {
let body = serde_json::json!({ "type": PERSONHOOD_CHALLENGE_TYPE, "id": "urn:uuid:1" });
let msg = didcomm_request(
"urn:uuid:1".into(),
body.clone(),
"did:key:zMember",
"did:key:zVtc",
);
assert_eq!(msg.typ, crate::capabilities::TRUST_TASK_ENVELOPE_TYPE);
assert_eq!(msg.id, "urn:uuid:1");
assert_eq!(msg.body, body);
assert_eq!(msg.from.as_deref(), Some("did:key:zMember"));
}
use super::*;
const MEMBER: &str = "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG";
fn secret() -> Secret {
Secret::from_multibase(
"z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq",
Some(&format!("{MEMBER}#key-0")),
)
.expect("test secret")
}
#[test]
fn response_types_are_their_request_types() {
assert_eq!(
PERSONHOOD_CHALLENGE_RESPONSE_TYPE,
format!("{PERSONHOOD_CHALLENGE_TYPE}#response")
);
assert_eq!(
PERSONHOOD_ASSERT_RESPONSE_TYPE,
format!("{PERSONHOOD_ASSERT_TYPE}#response")
);
}
#[test]
fn match_code_is_deterministic() {
let id = Uuid::parse_str("6f1c4f9e-7c2a-4f4b-9a3e-2b1d0c5e8a77").expect("uuid");
assert_eq!(match_code(&id), match_code(&id));
}
#[test]
fn match_code_is_four_dash_four_from_crockford() {
let code = match_code(&Uuid::new_v4());
assert_eq!(code.len(), CODE_CHARS + 1);
assert_eq!(code.as_bytes()[4], b'-');
for c in code.bytes().filter(|c| *c != b'-') {
assert!(CROCKFORD.contains(&c), "{} is outside Crockford", c as char);
}
for c in *b"ILOU" {
assert!(!CROCKFORD.contains(&c), "{} is confusable", c as char);
}
}
#[test]
fn match_code_agrees_with_the_vtc() {
let id = Uuid::parse_str("6f1c4f9e-7c2a-4f4b-9a3e-2b1d0c5e8a77").expect("uuid");
assert_eq!(match_code(&id), "5CY1-GZEE");
}
#[tokio::test]
async fn presentation_carries_the_challenge_signed_and_unsigned() {
let id = Uuid::new_v4();
let vp = build_presentation(&secret(), MEMBER, &id, vec![])
.await
.expect("build presentation");
assert_eq!(
vp["nonce"].as_str(),
Some(id.to_string().as_str()),
"the signed copy of the challenge is missing"
);
assert_eq!(
vp["proof"]["challenge"].as_str(),
Some(id.to_string().as_str()),
"the copy the published task names is missing"
);
assert_eq!(vp["holder"].as_str(), Some(MEMBER));
}
#[tokio::test]
async fn presentation_is_signed_under_authentication_proof_purpose() {
let vp = build_presentation(&secret(), MEMBER, &Uuid::new_v4(), vec![])
.await
.expect("build presentation");
assert_eq!(vp["proof"]["proofPurpose"].as_str(), Some("authentication"));
}
#[tokio::test]
async fn challenge_request_is_signed_under_authentication_proof_purpose() {
let doc = build_challenge_request(MEMBER, "did:key:zVtc", "urn:uuid:1", MEMBER, &secret())
.await
.expect("build challenge request");
assert_eq!(
doc["proof"]["proofPurpose"].as_str(),
Some("authentication")
);
}
const CHALLENGE: &str = "6f1c4f9e-7c2a-4f4b-9a3e-2b1d0c5e8a77";
fn challenge_response_document(ext: Value) -> Value {
json!({
"id": "urn:uuid:00000000-0000-4000-8000-000000000000",
"type": format!("{PERSONHOOD_CHALLENGE_TYPE}#response"),
"payload": {
"challengeId": CHALLENGE,
"expiresAt": "2026-08-24T10:15:00Z",
"ext": ext,
}
})
}
#[test]
fn challenge_reply_yields_the_nonce_and_the_spoken_code() {
let reply = parse_challenge_reply(&challenge_response_document(
json!({ "org.openvtc.match-code": "5CY1-GZEE" }),
))
.expect("parse challenge reply");
assert_eq!(reply.challenge_id.to_string(), CHALLENGE);
assert_eq!(reply.match_code, "5CY1-GZEE");
}
#[test]
fn challenge_reply_without_the_community_copy_still_derives_the_code() {
let mut doc = challenge_response_document(json!({}));
doc["payload"]
.as_object_mut()
.expect("payload object")
.remove("ext");
let reply = parse_challenge_reply(&doc).expect("parse challenge reply");
assert_eq!(reply.match_code, "5CY1-GZEE");
}
#[test]
fn challenge_reply_refuses_a_match_code_disagreement() {
let err = parse_challenge_reply(&challenge_response_document(
json!({ "org.openvtc.match-code": "0000-0000" }),
))
.expect_err("a disagreement must not pass silently");
assert!(
err.to_string().contains("drifted"),
"the error should name the cause, got: {err}"
);
}
#[test]
fn a_reply_without_a_payload_wrapper_is_read_whole() {
let bare = json!({
"did": MEMBER,
"personhood": true,
"vmc": { "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential",
"PersonhoodCredential"] },
"roleVac": { "type": ["VerifiableCredential", "DTGCredential", "AuthorityCredential"] },
});
let reply = parse_assert_reply(&bare).expect("parse assert reply");
assert!(reply.personhood);
assert_eq!(reply.did, MEMBER);
assert_eq!(
reply.role_vac["type"][2], "AuthorityCredential",
"the role credential is read from `roleVac`"
);
assert_eq!(
reply.vmc["type"][3].as_str(),
Some("PersonhoodCredential"),
"the re-minted VMC is what carries the claim"
);
}
#[tokio::test]
async fn the_signed_nonce_is_covered_by_the_proof() {
let id = Uuid::new_v4();
let vp = build_presentation(&secret(), MEMBER, &id, vec![])
.await
.expect("build presentation");
let proof: DataIntegrityProof =
serde_json::from_value(vp["proof"].clone()).expect("parse proof");
let holder = secret();
let pubkey = holder.get_public_bytes().to_vec();
let mut tampered = vp.clone();
tampered.as_object_mut().unwrap().remove("proof");
assert!(
proof
.verify_with_public_key(
&tampered,
&pubkey,
affinidi_data_integrity::VerifyOptions::new()
)
.is_ok(),
"the untampered presentation must verify"
);
tampered["nonce"] = Value::String(Uuid::new_v4().to_string());
assert!(
proof
.verify_with_public_key(
&tampered,
&pubkey,
affinidi_data_integrity::VerifyOptions::new()
)
.is_err(),
"swapping the nonce must break the proof — otherwise the challenge is not bound \
to anything and a captured presentation is replayable"
);
}
}