use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use affinidi_tdk::{TDK, didcomm::Message};
use dtg_credentials::DTGCredential;
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use uuid::Uuid;
use vta_sdk::protocols::join_requests::{
JoinRequestStatusResponseBody, JoinRequestSubmitReceiptBody, VerdictEffect, VerdictResponse,
};
use vta_sdk::protocols::members::{RemovalCode, RemovalNoticeBody};
use crate::config::Config;
use crate::config::account::{Account, DecisionEvidence, PersonaId, RelationshipIdentifierDefault};
use crate::issued_credential::VerifiedIssuedCredential;
use crate::relationships::{RelationshipState, Relationships};
use crate::tasks::{TaskType, Tasks};
pub const MAX_MESSAGE_AGE_SECS: u64 = 48 * 60 * 60;
pub const MAX_FUTURE_SKEW_SECS: u64 = 5 * 60;
pub const MAX_TASKS: usize = 10_000;
pub const MESSAGE_EXPIRY_SECS: u64 = 60 * 60 * 48;
pub fn build_didcomm_message(
type_url: &str,
body: serde_json::Value,
from: &str,
to: &str,
thid: Option<&str>,
) -> Result<Message, anyhow::Error> {
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let mut builder = Message::build(Uuid::new_v4().to_string(), type_url.to_string(), body)
.from(from.to_string())
.to(to.to_string())
.created_time(now)
.expires_time(now + MESSAGE_EXPIRY_SECS);
if let Some(t) = thid {
builder = builder.thid(t.to_string());
}
Ok(builder.finalize())
}
pub struct SeenMessages {
cap: usize,
order: VecDeque<String>,
set: HashSet<String>,
}
impl SeenMessages {
pub fn new() -> Self {
Self::with_capacity(1024)
}
pub fn with_capacity(cap: usize) -> Self {
Self {
cap,
order: VecDeque::with_capacity(cap),
set: HashSet::with_capacity(cap),
}
}
pub fn observe(&mut self, id: &str) -> bool {
if self.set.contains(id) {
return true;
}
if self.order.len() == self.cap
&& let Some(evicted) = self.order.pop_front()
{
self.set.remove(&evicted);
}
self.order.push_back(id.to_string());
self.set.insert(id.to_string());
false
}
}
impl Default for SeenMessages {
fn default() -> Self {
Self::new()
}
}
pub fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn check_message_age(message: &Message) -> Result<(), &'static str> {
let now = unix_now();
if let Some(created) = message.created_time {
if created > now.saturating_add(MAX_FUTURE_SKEW_SECS) {
return Err("created_time too far in future");
}
if now.saturating_sub(created) > MAX_MESSAGE_AGE_SECS {
return Err("created_time older than replay window");
}
}
if let Some(expires) = message.expires_time
&& expires < now
{
return Err("message already expired");
}
Ok(())
}
#[allow(clippy::result_unit_err)]
pub fn check_task_capacity(
config: &Config,
task_id: &Arc<String>,
from_did: &Arc<String>,
) -> Result<(), ()> {
if config.private.tasks.get_by_id(task_id).is_some() {
warn!(task_id = %task_id, from = %from_did, "rejecting duplicate task ID");
return Err(());
}
if config.private.tasks.tasks.len() >= MAX_TASKS {
warn!(
"task limit reached ({}) — rejecting inbound message",
MAX_TASKS
);
return Err(());
}
Ok(())
}
pub fn handle_join_submit_receipt(
account: &mut Account,
message: &Message,
from_did: &str,
) -> bool {
let Some(thid) = message.thid.as_deref() else {
warn!("join submit-receipt without thid — cannot correlate; ignoring");
return false;
};
let placeholder = match Uuid::parse_str(thid) {
Ok(u) => u,
Err(e) => {
warn!(thid, error = %e, "join submit-receipt thid is not a uuid — ignoring");
return false;
}
};
let body: JoinRequestSubmitReceiptBody =
match serde_json::from_value(trust_task_reply_payload(&message.body)) {
Ok(b) => b,
Err(e) => {
warn!(error = %e, "malformed join submit-receipt body — ignoring");
return false;
}
};
let Some(record) = account.membership_by_pending_request(from_did, placeholder) else {
warn!(
vtc = %from_did,
thid,
"join submit-receipt did not match a pending join with that id — ignoring",
);
return false;
};
record.confirm_request_id(body.request_id);
record.mark_acknowledged(chrono::Utc::now());
info!(
vtc = %from_did,
vtc_request_id = %body.request_id,
receipt_status = %body.status,
"reconciled join request id from VTC submit-receipt",
);
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CredentialIssueOutcome {
pub changed: bool,
pub closed_join: Option<(PersonaId, uuid::Uuid)>,
}
impl CredentialIssueOutcome {
pub const NONE: CredentialIssueOutcome = CredentialIssueOutcome {
changed: false,
closed_join: None,
};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProblemReportNote {
pub code: String,
pub comment: String,
pub on_pending_join: bool,
}
pub struct StatusOutcome {
pub changed: bool,
pub inactivated: Option<PersonaId>,
}
impl StatusOutcome {
const NONE: StatusOutcome = StatusOutcome {
changed: false,
inactivated: None,
};
}
fn trust_task_reply_payload(body: &Value) -> Value {
match body.get("payload") {
Some(payload) => payload.clone(),
None => body.clone(),
}
}
fn record_credential_delivery(
record: &mut crate::config::account::CommunityRecord,
body: &JoinRequestStatusResponseBody,
now: chrono::DateTime<chrono::Utc>,
) -> bool {
use crate::config::account::{CredentialDelivery, CredentialResendAnswer};
use vta_sdk::protocols::join_requests::CredentialResend;
let Some(delivered) = body.credentials_delivered else {
return false;
};
let previous = record.credential_delivery.clone();
let last_answer = match body.credential_resend {
Some(CredentialResend::Queued) => Some(CredentialResendAnswer::Queued { at: now }),
Some(CredentialResend::RateLimited) => Some(CredentialResendAnswer::RateLimited {
retry_after: body.retry_after,
}),
Some(CredentialResend::NotNeeded) => Some(CredentialResendAnswer::NotNeeded),
None => previous.as_ref().and_then(|d| d.last_answer.clone()),
};
let next = CredentialDelivery {
delivered,
last_answer,
};
if previous.as_ref() == Some(&next) {
return false;
}
record.credential_delivery = Some(next);
true
}
pub fn handle_join_status_response(
account: &mut Account,
message: &Message,
from_did: &str,
) -> StatusOutcome {
let body: JoinRequestStatusResponseBody =
match serde_json::from_value(trust_task_reply_payload(&message.body)) {
Ok(b) => b,
Err(e) => {
warn!(error = %e, "malformed join status-response body — ignoring");
return StatusOutcome::NONE;
}
};
let Some(record) = account.membership_by_pending_request(from_did, body.request_id) else {
warn!(vtc = %from_did, "status-response did not match a pending request id — ignoring");
return StatusOutcome::NONE;
};
let persona = record.persona_ref;
match body.status.as_str() {
"approved" => {
let now = chrono::Utc::now();
let acknowledged = record.mark_acknowledged(now);
let approved = record.mark_approved(now);
let delivery = record_credential_delivery(record, &body, now);
let changed = acknowledged || approved || delivery;
info!(vtc = %from_did, "join approved by VTC — awaiting the membership credential");
StatusOutcome {
changed,
inactivated: None,
}
}
"rejected" => {
record.reject(DecisionEvidence {
code: body.code.clone(),
reason: body.reason.clone(),
decided_by: None,
decided_at: body.decided_at,
disposition: None,
});
info!(
vtc = %from_did,
code = body.code.as_deref().unwrap_or(""),
"join rejected by VTC"
);
StatusOutcome {
changed: true,
inactivated: Some(persona),
}
}
"withdrawn" => {
let changed = record.withdraw();
info!(vtc = %from_did, "join withdrawn — reconciled to Withdrawn");
StatusOutcome {
changed,
inactivated: changed.then_some(persona),
}
}
"deferred" => {
let changed = record.mark_acknowledged(chrono::Utc::now());
info!(
vtc = %from_did,
needs = ?body.needs,
"join deferred — more information required (handling deferred to D4)"
);
StatusOutcome {
changed,
inactivated: None,
}
}
other => {
debug!(vtc = %from_did, status = %other, "status-response: no transition");
StatusOutcome::NONE
}
}
}
pub fn handle_join_verdict(
account: &mut Account,
message: &Message,
from_did: &str,
) -> StatusOutcome {
let Some(thid) = message.thid.as_deref() else {
warn!("join verdict without thid — cannot correlate; ignoring");
return StatusOutcome::NONE;
};
let Ok(placeholder) = Uuid::parse_str(thid) else {
warn!(thid = %thid, "join verdict thid is not a uuid — ignoring");
return StatusOutcome::NONE;
};
let body: VerdictResponse =
match serde_json::from_value(trust_task_reply_payload(&message.body)) {
Ok(b) => b,
Err(e) => {
warn!(error = %e, "malformed join verdict body — ignoring");
return StatusOutcome::NONE;
}
};
let Some(record) = account.membership_by_pending_request(from_did, placeholder) else {
warn!(vtc = %from_did, "verdict did not match a pending request id — ignoring");
return StatusOutcome::NONE;
};
let persona = record.persona_ref;
match body.verdict.effect {
VerdictEffect::Allow => {
let changed = record.confirm_request_id(body.request_id)
| record.mark_acknowledged(chrono::Utc::now());
info!(vtc = %from_did, "join allowed by VTC — awaiting the membership credential");
StatusOutcome {
changed,
inactivated: None,
}
}
VerdictEffect::Deny => {
record.confirm_request_id(body.request_id);
record.mark_acknowledged(chrono::Utc::now());
record.reject(DecisionEvidence {
code: body.verdict.with.code.clone(),
reason: body.verdict.with.reason.clone(),
decided_by: None,
decided_at: None,
disposition: None,
});
info!(
vtc = %from_did,
code = body.verdict.with.code.as_deref().unwrap_or(""),
reason = body.verdict.with.reason.as_deref().unwrap_or(""),
"join denied by VTC policy — now Rejected"
);
StatusOutcome {
changed: true,
inactivated: Some(persona),
}
}
VerdictEffect::Refer => {
let changed = record.confirm_request_id(body.request_id)
| record.mark_acknowledged(chrono::Utc::now());
info!(
vtc = %from_did,
queue = body.verdict.with.queue.as_deref().unwrap_or(""),
"join referred for human review — stays Pending"
);
StatusOutcome {
changed,
inactivated: None,
}
}
VerdictEffect::RequestMore => {
let changed = record.confirm_request_id(body.request_id)
| record.mark_acknowledged(chrono::Utc::now());
info!(
vtc = %from_did,
needs = ?body.verdict.with.needs,
"join needs more evidence — stays Pending"
);
StatusOutcome {
changed,
inactivated: None,
}
}
}
}
#[must_use]
pub fn join_verdict_needs(message: &Message) -> Option<Vec<String>> {
let body: VerdictResponse =
serde_json::from_value(trust_task_reply_payload(&message.body)).ok()?;
matches!(body.verdict.effect, VerdictEffect::RequestMore).then_some(body.verdict.with.needs)
}
#[must_use]
pub fn describe_join_needs(needs: &[String]) -> String {
if needs.is_empty() {
return "it needs more evidence, but did not say what".to_string();
}
let words: Vec<String> = needs
.iter()
.map(|need| match need.as_str() {
"credentials" => "a credential it recognises".to_string(),
"invitation" => "an invitation".to_string(),
"vetting:consistency" => "vetting statements that agree on who you are".to_string(),
"vetting:independence" => {
"vetting statements from vetters independent of you and of each other".to_string()
}
other => match other.strip_prefix("vetting:") {
Some(what) => format!("more vetting ({what})"),
None => other.to_string(),
},
})
.collect();
format!("it still needs {}", words.join(", "))
}
#[must_use]
pub fn read_problem_report(
account: &Account,
message: &Message,
from_did: &str,
) -> Option<ProblemReportNote> {
if account.memberships_for(from_did).is_empty() {
return None;
}
let (code, comment) = vta_sdk::protocols::extract_problem_report(&message.body);
let on_pending_join = message
.thid
.as_deref()
.and_then(|t| Uuid::parse_str(t).ok())
.is_some_and(|id| {
account
.memberships_for(from_did)
.iter()
.any(|m| matches!(m.status, crate::config::account::CommunityStatus::Pending { request_id } if request_id == id))
});
Some(ProblemReportNote {
code,
comment,
on_pending_join,
})
}
pub const TRUST_TASK_ERROR_TYPE_PREFIX: &str = "https://trusttasks.org/spec/trust-task-error/";
pub fn is_trust_task_error_type(typ: &str) -> bool {
typ.starts_with(TRUST_TASK_ERROR_TYPE_PREFIX)
}
fn is_join_denial_code(code: &str) -> bool {
matches!(code, "permissionDenied" | "forbidden" | "identityMismatch")
|| join_refusal_reason(code).is_some()
}
fn join_refusal_reason(code: &str) -> Option<&'static str> {
use trust_tasks_rs::specs::vtc::join_requests::submit::v0_3::error_codes;
if code == error_codes::NOT_ACCEPTING.code {
Some("the community is not accepting applications right now")
} else if code == error_codes::CRITERION_UNKNOWN.code {
Some(
"the community no longer publishes the criterion this join was made under — \
join again to apply under its current requirements",
)
} else {
None
}
}
pub fn handle_join_trust_task_error(
account: &mut Account,
message: &Message,
from_did: &str,
) -> StatusOutcome {
let Some(thid) = message.thid.as_deref() else {
warn!("join trust-task-error without thid — cannot correlate; ignoring");
return StatusOutcome::NONE;
};
let Ok(placeholder) = Uuid::parse_str(thid) else {
warn!(thid = %thid, "join trust-task-error thid is not a uuid — ignoring");
return StatusOutcome::NONE;
};
let code = message
.body
.pointer("/payload/code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let detail = message
.body
.pointer("/payload/message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let Some(record) = account.membership_by_pending_request(from_did, placeholder) else {
warn!(vtc = %from_did, code = %code, "trust-task-error did not match a pending request id — ignoring");
return StatusOutcome::NONE;
};
if is_join_denial_code(&code) {
let reason = if detail.is_empty() {
join_refusal_reason(&code).map(str::to_string)
} else {
Some(detail.clone())
};
record.reject(DecisionEvidence {
code: (!code.is_empty()).then(|| code.clone()),
reason,
decided_by: None,
decided_at: None,
disposition: None,
});
info!(
vtc = %from_did,
code = %code,
detail = %detail,
"join denied by VTC (trust-task-error) — now Rejected"
);
StatusOutcome {
changed: true,
inactivated: Some(record.persona_ref),
}
} else {
let changed = record.mark_acknowledged(chrono::Utc::now());
warn!(
vtc = %from_did,
code = %code,
detail = %detail,
"join submit failed (trust-task-error) — left Pending (recoverable)"
);
StatusOutcome {
changed,
inactivated: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RemovalNoticeError {
#[error("the notice {0}")]
Document(#[from] crate::operational::OperationalError),
#[error("the notice's payload is malformed")]
Malformed,
#[error("the notice is addressed to one persona but names another")]
WrongRecipient,
#[error("the notice is for a membership we do not hold with that community")]
NoMembership,
}
#[derive(Debug, Clone)]
pub struct VerifiedRemovalNotice(RemovalNoticeBody);
impl VerifiedRemovalNotice {
#[cfg(test)]
pub(crate) fn assume_verified(body: RemovalNoticeBody) -> Self {
Self(body)
}
}
pub async fn verify_removal_notice(
message: &Message,
from_did: &str,
account: &Account,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
seen: &mut crate::operational::SeenDocuments,
now: chrono::DateTime<chrono::Utc>,
) -> Result<VerifiedRemovalNotice, RemovalNoticeError> {
let ours: Vec<&str> = account.personas.values().map(|p| p.did.as_str()).collect();
let verified = crate::operational::verify_operational(
&message.body,
from_did,
&ours,
vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE,
resolver,
seen,
now,
)
.await;
bind_removal_notice(message, from_did, account, seen, verified, now)
}
pub fn bind_removal_notice(
message: &Message,
from_did: &str,
account: &Account,
seen: &mut crate::operational::SeenDocuments,
verified: Result<crate::operational::VerifiedOperational, crate::operational::OperationalError>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<VerifiedRemovalNotice, RemovalNoticeError> {
let document = &message.body;
let body: RemovalNoticeBody = document
.get("payload")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|_| RemovalNoticeError::Malformed)?
.ok_or(crate::operational::OperationalError::NotADocument)?;
if let Some(recipient) = document.get("recipient").and_then(Value::as_str)
&& recipient != body.did
{
return Err(RemovalNoticeError::WrongRecipient);
}
let verified = verified?;
let bound = account
.persona_id_for_did(&body.did)
.is_some_and(|persona| account.membership(from_did, persona).is_some());
if !bound {
return Err(RemovalNoticeError::NoMembership);
}
verified.check(seen, now)?;
verified.commit(seen, now)?;
Ok(VerifiedRemovalNotice(body))
}
pub fn handle_member_removal_notice(
account: &mut Account,
notice: VerifiedRemovalNotice,
from_did: &str,
) -> StatusOutcome {
let body = notice.0;
let Some(persona) = account.persona_id_for_did(&body.did) else {
warn!(
vtc = %from_did,
"removal-notice names a DID that is not one of our personas — ignoring"
);
return StatusOutcome::NONE;
};
let Some(record) = account.membership_mut(from_did, persona) else {
warn!(
vtc = %from_did,
"removal-notice for a community we hold no membership of as that persona — ignoring"
);
return StatusOutcome::NONE;
};
if !record.status.is_active() {
info!(
vtc = %from_did,
status = ?record.status,
"removal-notice for a non-active membership — ignoring"
);
return StatusOutcome::NONE;
}
let decided_at = chrono::DateTime::parse_from_rfc3339(&body.decided_at)
.map(|d| d.with_timezone(&chrono::Utc))
.ok();
let code = match body.code {
RemovalCode::AdminRemoved => "adminRemoved",
RemovalCode::Purged => "purged",
};
record.remove(DecisionEvidence {
code: Some(code.to_string()),
reason: body.reason.clone(),
decided_by: Some(body.decided_by.clone()),
decided_at,
disposition: Some(body.disposition.clone()),
});
info!(
vtc = %from_did,
code = %code,
disposition = %body.disposition,
decided_by = %body.decided_by,
"removed from community by VTC — now Removed"
);
StatusOutcome {
changed: true,
inactivated: Some(persona),
}
}
pub fn handle_community_profile_show_response(
account: &mut Account,
message: &Message,
from_did: &str,
) -> bool {
let payload = trust_task_reply_payload(&message.body);
let declared = match payload
.pointer("/profile/relationshipIdentifierDefault")
.and_then(Value::as_str)
{
Some("attributed") => Some(RelationshipIdentifierDefault::Attributed),
Some("pairwise") => Some(RelationshipIdentifierDefault::Pairwise),
None => None,
Some(other) => {
warn!(
vtc = %from_did,
value = %other,
"community profile: unrecognised relationshipIdentifierDefault — treating as undeclared"
);
None
}
};
let mut records = account
.memberships_mut()
.filter(|c| c.vtc_did == from_did)
.peekable();
if records.peek().is_none() {
warn!(
vtc = %from_did,
"community profile response from a VTC we hold no membership with — ignoring"
);
return false;
}
let mut changed = false;
for record in records {
if declared == Some(RelationshipIdentifierDefault::Attributed)
&& record.relationship_identifier_default
!= Some(RelationshipIdentifierDefault::Attributed)
{
info!(
"community declares attributed relationship identifiers — kept pairwise; \
choose attributed per relationship if you want it"
);
continue;
}
if record.relationship_identifier_default != declared {
record.relationship_identifier_default = declared;
changed = true;
}
}
debug!(
vtc = %from_did,
default = ?declared,
"recorded community relationship-identifier default"
);
changed
}
#[must_use]
pub fn credential_in_issue(message: &Message) -> Option<Value> {
let issuer = message.body.get("issuer").and_then(Value::as_str)?;
if Some(issuer) != message.from.as_deref() {
return None;
}
message
.body
.pointer("/payload/credential_response/credential")
.cloned()
}
pub fn credential_issue_admissible(
account: &Account,
credential: &Value,
from_did: &str,
) -> Result<(crate::config::account::PersonaId, crate::CredentialKind), &'static str> {
if crate::issued_credential::issuer_of(credential) != Some(from_did) {
return Err("its issuer is not the community that sent it");
}
let subject = credential
.get("credentialSubject")
.and_then(|s| s.get("id"))
.and_then(Value::as_str)
.ok_or("it has no subject")?;
let persona_id = account
.persona_id_for_did(subject)
.ok_or("its subject is not one of our personas")?;
let kind =
crate::CredentialKind::from_credential(credential).ok_or("it is of no known kind")?;
let record = account
.membership(from_did, persona_id)
.ok_or("we hold no membership with that community for its subject")?;
let pending = matches!(
record.status,
crate::config::account::CommunityStatus::Pending { .. }
);
if !pending && !record.status.is_active() {
return Err("it is for a membership that has ended");
}
Ok((persona_id, kind))
}
pub fn handle_credential_issue(
account: &mut Account,
credential: VerifiedIssuedCredential,
from_did: &str,
) -> CredentialIssueOutcome {
let credential = credential.into_value();
let (persona_id, kind) = match credential_issue_admissible(account, &credential, from_did) {
Ok(target) => target,
Err(reason) => {
warn!(vtc = %from_did, "issued credential ignored: {reason}");
return CredentialIssueOutcome::NONE;
}
};
let Some(record) = account.membership_mut(from_did, persona_id) else {
return CredentialIssueOutcome::NONE;
};
record.credentials.insert(kind, credential);
record.clear_retired(kind.config_key());
let closed_join = match record.status {
crate::config::account::CommunityStatus::Pending { request_id }
if kind.activates_membership() =>
{
record.activate(chrono::Utc::now());
Some((persona_id, request_id))
}
_ => None,
};
info!(
vtc = %from_did,
credential_kind = %kind.config_key(),
activates_membership = kind.activates_membership(),
"stored issued credential",
);
CredentialIssueOutcome {
changed: true,
closed_join,
}
}
pub fn vet_vrc_issued(
relationships: &Relationships,
tasks: &Tasks,
vrc: &DTGCredential,
from_did: &Arc<String>,
thid: Option<&str>,
) -> Result<Option<Arc<String>>, String> {
let relationship = relationships
.find_by_remote_did(from_did)
.ok_or_else(|| "no relationship with sender".to_string())?;
if relationship.state != RelationshipState::Established {
return Err(format!(
"relationship with sender is not established (state: {})",
relationship.state
));
}
let remote_p_did = Arc::clone(&relationship.remote_p_did);
if vrc.issuer() != relationship.remote_did.as_str() {
return Err(format!(
"credential issuer ({}) is not the DID the sender uses in this \
relationship ({})",
vrc.issuer(),
relationship.remote_did
));
}
let pending_request = thid.and_then(|thid| {
let id = Arc::new(thid.to_string());
let task = tasks.get_by_id(&id)?;
let TaskType::VRCRequestOutbound {
remote_p_did: task_remote_p_did,
} = &task.type_
else {
return None;
};
(*task_remote_p_did == remote_p_did).then(|| Arc::clone(&id))
});
Ok(pending_request)
}
pub async fn verify_vrc_proof(tdk: &TDK, vrc: &DTGCredential) -> Result<(), String> {
let document = serde_json::to_value(vrc)
.map_err(|e| format!("the credential could not be read for verification: {e}"))?;
crate::proof_check::verify_signed(
&document,
vrc.issuer(),
tdk.did_resolver(),
&[crate::proof_check::Purpose::AssertionMethod],
)
.await
.map_err(|e| format!("proof verification failed: {e}"))
}
pub fn verify_vrc_proof_with_key(
vrc: &DTGCredential,
public_key_bytes: &[u8],
) -> Result<(), String> {
check_vrc_issuer_binding(vrc)?;
vrc.verify_proof_with_public_key(public_key_bytes)
.map_err(|e| format!("proof verification failed: {e}"))
}
fn check_vrc_issuer_binding(
vrc: &DTGCredential,
) -> Result<affinidi_data_integrity::DataIntegrityProof, String> {
let Some(proof) = vrc.credential().proof.clone() else {
return Err("credential has no data-integrity proof".to_string());
};
let vm_did = proof
.verification_method
.split_once('#')
.map_or(proof.verification_method.as_str(), |(did, _)| did);
if vm_did != vrc.issuer() {
return Err(format!(
"proof verification method ({}) does not belong to the issuer ({})",
proof.verification_method,
vrc.issuer()
));
}
Ok(proof)
}
pub fn require_thid(message: &Message) -> Result<Arc<String>, anyhow::Error> {
message
.thid
.as_ref()
.map(|s| Arc::new(s.to_string()))
.ok_or_else(|| anyhow::anyhow!("message missing required 'thid' header"))
}
pub fn validate_did(did: &str) -> Result<(), anyhow::Error> {
let bail = || -> anyhow::Error {
anyhow::anyhow!(
"invalid DID format: '{}'",
crate::display::truncate_chars(did, 64)
)
};
let rest = did.strip_prefix("did:").ok_or_else(bail)?;
let (method, msi) = rest.split_once(':').ok_or_else(bail)?;
if method.is_empty()
|| !method
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return Err(bail());
}
if msi.is_empty() {
return Err(bail());
}
let mut segments = msi.split(':');
let last_segment_nonempty = msi.split(':').next_back().is_some_and(|s| !s.is_empty());
if !last_segment_nonempty {
return Err(bail());
}
if !segments.all(|seg| seg.chars().all(is_did_msi_char)) {
return Err(bail());
}
Ok(())
}
pub fn is_did_msi_char(c: char) -> bool {
matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' | '%')
}
pub fn create_finalize_message(
from: &str,
to: &str,
task_id: &Arc<String>,
) -> Result<Message, anyhow::Error> {
build_didcomm_message(
crate::protocol_urls::RELATIONSHIP_REQUEST_FINALIZE,
json!({}),
from,
to,
Some(task_id.as_str()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::account::CommunityStatus;
fn msg(id: &str, created: Option<u64>, expires: Option<u64>) -> Message {
let mut m =
Message::build(id.to_string(), "test".to_string(), serde_json::json!({})).finalize();
m.created_time = created;
m.expires_time = expires;
m
}
#[test]
fn seen_messages_marks_first_observation_unseen() {
let mut seen = SeenMessages::with_capacity(4);
assert!(!seen.observe("a"));
}
#[test]
fn seen_messages_detects_replay() {
let mut seen = SeenMessages::with_capacity(4);
assert!(!seen.observe("a"));
assert!(seen.observe("a"));
}
#[test]
fn seen_messages_evicts_oldest_at_capacity() {
let mut seen = SeenMessages::with_capacity(2);
assert!(!seen.observe("a"));
assert!(!seen.observe("b"));
assert!(seen.observe("b"));
assert!(!seen.observe("c"));
assert!(!seen.observe("a"));
}
#[test]
fn check_message_age_accepts_message_with_no_timestamps() {
assert!(check_message_age(&msg("id", None, None)).is_ok());
}
#[test]
fn check_message_age_rejects_old_messages() {
let now = unix_now();
let too_old = now - MAX_MESSAGE_AGE_SECS - 60;
assert!(check_message_age(&msg("id", Some(too_old), None)).is_err());
}
#[test]
fn check_message_age_rejects_future_messages() {
let now = unix_now();
let too_future = now + MAX_FUTURE_SKEW_SECS + 60;
assert!(check_message_age(&msg("id", Some(too_future), None)).is_err());
}
#[test]
fn check_message_age_accepts_within_skew() {
let now = unix_now();
assert!(check_message_age(&msg("id", Some(now + 60), None)).is_ok());
}
#[test]
fn check_message_age_rejects_expired_messages() {
let now = unix_now();
assert!(check_message_age(&msg("id", Some(now), Some(now - 60))).is_err());
}
use crate::config::account::{Account, CommunityRecord, PersonaId, PersonaRecord};
use chrono::Utc;
use vta_sdk::protocols::credential_exchange::ISSUE as CREDENTIAL_ISSUE_TYPE;
use vta_sdk::protocols::join_requests::{
JOIN_REQUEST_STATUS_RESPONSE_TYPE, JOIN_REQUEST_SUBMIT_RECEIPT_TYPE,
};
fn only<'a>(acct: &'a Account, vtc: &str) -> &'a CommunityRecord {
acct.memberships()
.find(|c| c.vtc_did == vtc)
.expect("membership present")
}
fn pending_account(vtc: &str, placeholder: Uuid) -> Account {
let mut acct = Account::default();
acct.add_membership(CommunityRecord::new_pending(
vtc.to_string(),
None,
"openvtc/x".to_string(),
PersonaId::new(),
placeholder,
Utc::now(),
));
acct
}
fn receipt(thid: &str, from: &str, request_id: Uuid, status: &str) -> Message {
Message::build(
Uuid::new_v4().to_string(),
JOIN_REQUEST_SUBMIT_RECEIPT_TYPE.to_string(),
serde_json::json!({ "requestId": request_id, "status": status }),
)
.from(from.to_string())
.thid(thid.to_string())
.finalize()
}
#[test]
fn submit_receipt_reconciles_the_authoritative_request_id() {
let vtc = "did:webvh:example:vtc";
let placeholder = Uuid::new_v4();
let mut acct = pending_account(vtc, placeholder);
let real = Uuid::new_v4();
let m = receipt(&placeholder.to_string(), vtc, real, "pending");
assert!(handle_join_submit_receipt(&mut acct, &m, vtc));
match &only(&acct, vtc).status {
CommunityStatus::Pending { request_id } => assert_eq!(*request_id, real),
other => panic!("expected Pending, got {other:?}"),
}
}
#[test]
fn submit_receipt_from_a_different_did_is_ignored() {
let vtc = "did:webvh:example:vtc";
let placeholder = Uuid::new_v4();
let mut acct = pending_account(vtc, placeholder);
let m = receipt(
&placeholder.to_string(),
"did:webvh:evil",
Uuid::new_v4(),
"pending",
);
assert!(!handle_join_submit_receipt(&mut acct, &m, "did:webvh:evil"));
match &only(&acct, vtc).status {
CommunityStatus::Pending { request_id } => assert_eq!(*request_id, placeholder),
other => panic!("expected unchanged Pending, got {other:?}"),
}
}
#[test]
fn submit_receipt_with_mismatched_thid_is_ignored() {
let vtc = "did:webvh:example:vtc";
let placeholder = Uuid::new_v4();
let mut acct = pending_account(vtc, placeholder);
let m = receipt(&Uuid::new_v4().to_string(), vtc, Uuid::new_v4(), "pending");
assert!(!handle_join_submit_receipt(&mut acct, &m, vtc));
match &only(&acct, vtc).status {
CommunityStatus::Pending { request_id } => assert_eq!(*request_id, placeholder),
other => panic!("expected unchanged Pending, got {other:?}"),
}
}
fn status_response(from: &str, request_id: Uuid, status: &str) -> Message {
Message::build(
Uuid::new_v4().to_string(),
JOIN_REQUEST_STATUS_RESPONSE_TYPE.to_string(),
serde_json::json!({ "requestId": request_id, "status": status }),
)
.from(from.to_string())
.finalize()
}
fn status_response_document(from: &str, request_id: Uuid, status: &str) -> Message {
Message::build(
Uuid::new_v4().to_string(),
JOIN_REQUEST_STATUS_RESPONSE_TYPE.to_string(),
serde_json::json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"threadId": format!("urn:uuid:{}", Uuid::new_v4()),
"type": JOIN_REQUEST_STATUS_RESPONSE_TYPE,
"issuer": from,
"payload": { "requestId": request_id, "status": status },
}),
)
.from(from.to_string())
.finalize()
}
fn profile_response(from: &str, relationship_identifier_default: Option<&str>) -> Message {
let mut profile = serde_json::json!({
"communityDid": from,
"name": "Acme",
"language": "en",
});
if let Some(v) = relationship_identifier_default {
profile["relationshipIdentifierDefault"] = serde_json::json!(v);
}
Message::build(
Uuid::new_v4().to_string(),
crate::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE.to_string(),
serde_json::json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"type": crate::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE,
"issuer": from,
"payload": { "profile": profile },
}),
)
.from(from.to_string())
.finalize()
}
#[test]
fn profile_response_records_pairwise_but_never_weakens_to_attributed() {
let vtc = "did:webvh:example:vtc";
let mut acct = pending_account(vtc, Uuid::new_v4());
assert!(!handle_community_profile_show_response(
&mut acct,
&profile_response(vtc, Some("attributed")),
vtc,
));
assert_eq!(only(&acct, vtc).relationship_identifier_default, None);
let mut acct = pending_account(vtc, Uuid::new_v4());
assert!(handle_community_profile_show_response(
&mut acct,
&profile_response(vtc, Some("pairwise")),
vtc,
));
assert_eq!(
only(&acct, vtc).relationship_identifier_default,
Some(RelationshipIdentifierDefault::Pairwise)
);
}
#[test]
fn profile_response_undeclared_or_unknown_stays_none() {
let vtc = "did:webvh:example:vtc";
let mut acct = pending_account(vtc, Uuid::new_v4());
handle_community_profile_show_response(&mut acct, &profile_response(vtc, None), vtc);
assert_eq!(only(&acct, vtc).relationship_identifier_default, None);
let mut acct = pending_account(vtc, Uuid::new_v4());
handle_community_profile_show_response(
&mut acct,
&profile_response(vtc, Some("someFutureForm")),
vtc,
);
assert_eq!(only(&acct, vtc).relationship_identifier_default, None);
}
#[test]
fn profile_response_from_a_stranger_is_ignored() {
let vtc = "did:webvh:example:vtc";
let mut acct = pending_account(vtc, Uuid::new_v4());
let changed = handle_community_profile_show_response(
&mut acct,
&profile_response("did:webvh:example:other", Some("attributed")),
"did:webvh:example:other",
);
assert!(!changed);
assert_eq!(only(&acct, vtc).relationship_identifier_default, None);
}
#[test]
fn status_response_approved_is_read_from_response_document() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_status_response(
&mut acct,
&status_response_document(vtc, rid, "approved"),
vtc,
);
assert!(
out.changed,
"an approved status response in its wire form is read (and acknowledged)"
);
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
}
#[test]
fn reply_payload_accepts_document_and_bare_shapes() {
let bare = serde_json::json!({ "requestId": "r", "status": "approved" });
assert_eq!(trust_task_reply_payload(&bare), bare);
let document = serde_json::json!({
"id": "urn:uuid:1",
"type": "https://example.org/x/1.0#response",
"payload": bare.clone(),
});
assert_eq!(trust_task_reply_payload(&document), bare);
}
#[test]
fn status_response_approved_awaits_the_credential() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out =
handle_join_status_response(&mut acct, &status_response(vtc, rid, "approved"), vtc);
assert!(out.changed, "the approval is acknowledged");
assert!(out.inactivated.is_none(), "approval keeps the live session");
let rec = only(&acct, vtc);
assert!(!rec.status.is_active(), "no credential yet, so not Active");
assert!(rec.member_since.is_none());
assert!(rec.approved_at.is_some());
assert!(rec.approved_awaiting_credential() && rec.can_renew());
assert!(!rec.pending_unacknowledged(chrono::Utc::now() + chrono::TimeDelta::days(1)));
let again =
handle_join_status_response(&mut acct, &status_response(vtc, rid, "approved"), vtc);
assert!(!again.changed);
}
fn approved_reply(vtc: &str, rid: Uuid, extra: serde_json::Value) -> Message {
let mut body = serde_json::json!({ "requestId": rid, "status": "approved" });
body.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
Message::build(
Uuid::new_v4().to_string(),
JOIN_REQUEST_STATUS_RESPONSE_TYPE.to_string(),
body,
)
.from(vtc.to_string())
.finalize()
}
#[test]
fn status_response_approved_records_credential_delivery() {
use crate::config::account::{CredentialDelivery, CredentialResendAnswer};
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
handle_join_status_response(&mut acct, &approved_reply(vtc, rid, json!({})), vtc);
assert_eq!(
only(&acct, vtc).credential_delivery,
None,
"an older community"
);
let out = handle_join_status_response(
&mut acct,
&approved_reply(vtc, rid, json!({ "credentialsDelivered": false })),
vtc,
);
assert!(out.changed);
assert_eq!(
only(&acct, vtc).credential_delivery,
Some(CredentialDelivery {
delivered: false,
last_answer: None
})
);
let retry = "2026-10-02T12:00:00Z";
handle_join_status_response(
&mut acct,
&approved_reply(
vtc,
rid,
json!({ "credentialsDelivered": false, "credentialResend": "rateLimited", "retryAfter": retry }),
),
vtc,
);
let expected = Some(CredentialResendAnswer::RateLimited {
retry_after: Some(retry.parse().unwrap()),
});
assert_eq!(
only(&acct, vtc)
.credential_delivery
.as_ref()
.unwrap()
.last_answer,
expected
);
let again = handle_join_status_response(
&mut acct,
&approved_reply(vtc, rid, json!({ "credentialsDelivered": false })),
vtc,
);
assert!(!again.changed);
assert_eq!(
only(&acct, vtc)
.credential_delivery
.as_ref()
.unwrap()
.last_answer,
expected
);
handle_join_status_response(
&mut acct,
&approved_reply(
vtc,
rid,
json!({ "credentialsDelivered": false, "credentialResend": "queued" }),
),
vtc,
);
assert!(matches!(
only(&acct, vtc)
.credential_delivery
.as_ref()
.unwrap()
.last_answer,
Some(CredentialResendAnswer::Queued { .. })
));
}
#[test]
fn status_response_rejected_inactivates_and_raises_badge() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out =
handle_join_status_response(&mut acct, &status_response(vtc, rid, "rejected"), vtc);
assert!(out.changed);
assert!(
out.inactivated.is_some(),
"a rejection must deregister the session (R-S-3)"
);
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Rejected));
assert!(
rec.needs_attention(),
"an unacknowledged rejection nags (R-S-2)"
);
}
#[test]
fn status_response_rejected_persists_decision_evidence() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let message = Message::build(
Uuid::new_v4().to_string(),
JOIN_REQUEST_STATUS_RESPONSE_TYPE.to_string(),
serde_json::json!({
"requestId": rid,
"status": "rejected",
"code": "admin-reject",
"reason": "not this time",
"decidedAt": "2026-08-23T09:14:02Z",
}),
)
.from(vtc.to_string())
.finalize();
assert!(handle_join_status_response(&mut acct, &message, vtc).changed);
let d = only(&acct, vtc)
.decision
.clone()
.expect("a rejection records decision evidence");
assert_eq!(d.code.as_deref(), Some("admin-reject"));
assert_eq!(d.reason.as_deref(), Some("not this time"));
assert!(d.decided_at.is_some(), "the decision time is persisted");
assert!(
d.decided_by.is_none(),
"a join rejection names no verifiable authority"
);
}
#[test]
fn status_response_rejected_without_evidence_is_empty_not_absent() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
assert!(
handle_join_status_response(&mut acct, &status_response(vtc, rid, "rejected"), vtc)
.changed
);
let d = only(&acct, vtc)
.decision
.clone()
.expect("even an evidence-free rejection records a (empty) decision");
assert!(d.is_empty(), "no fields travelled — 'no reason given'");
}
#[test]
fn verdict_deny_persists_evidence_and_acknowledges() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let with = serde_json::json!({ "code": "policy.denied", "reason": "membership full" });
let out = handle_join_verdict(
&mut acct,
&verdict(&rid.to_string(), vtc, "deny", with),
vtc,
);
assert!(out.changed);
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Rejected));
assert!(
rec.receipt_at.is_some(),
"a denial is a correlated response — it must stamp receipt_at, \
not read like a dropped submit"
);
assert!(
rec.request_id_confirmed,
"the deny arm adopts the VTC's request id, like refer/request_more"
);
let d = rec.decision.clone().expect("deny records evidence");
assert_eq!(d.code.as_deref(), Some("policy.denied"));
assert_eq!(d.reason.as_deref(), Some("membership full"));
}
#[test]
fn trust_task_error_denial_persists_code_and_detail() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_trust_task_error(
&mut acct,
&trust_task_error(&rid.to_string(), vtc, "permissionDenied", "not on the list"),
vtc,
);
assert!(out.changed);
let d = only(&acct, vtc)
.decision
.clone()
.expect("denial records evidence");
assert_eq!(d.code.as_deref(), Some("permissionDenied"));
assert_eq!(d.reason.as_deref(), Some("not on the list"));
}
fn removal_notice(_from: &str, body: serde_json::Value) -> VerifiedRemovalNotice {
VerifiedRemovalNotice::assume_verified(serde_json::from_value(body).expect("a payload"))
}
fn did_key_secret(seed: u8) -> affinidi_tdk::secrets_resolver::secrets::Secret {
let mut secret = affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(
None,
Some(&[seed; 32]),
);
let public = secret.get_public_keymultibase().unwrap();
secret.id = format!("did:key:{public}#{public}");
secret
}
fn did_of_secret(secret: &affinidi_tdk::secrets_resolver::secrets::Secret) -> String {
secret.id.split('#').next().unwrap().to_string()
}
fn notice_document(vtc: &str, persona: &str) -> serde_json::Value {
serde_json::json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"type": vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE,
"issuer": vtc,
"recipient": persona,
"issuedAt": Utc::now().to_rfc3339(),
"payload": {
"did": persona,
"code": "adminRemoved",
"disposition": "tombstone",
"decidedAt": "2026-08-23T09:14:02Z",
"decidedBy": "did:key:z6MkAdmin",
},
})
}
fn notice_message(from: &str, body: serde_json::Value) -> Message {
Message::build(
Uuid::new_v4().to_string(),
vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE.to_string(),
body,
)
.from(from.to_string())
.finalize()
}
async fn test_resolver() -> affinidi_did_resolver_cache_sdk::DIDCacheClient {
affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.expect("resolver")
}
#[tokio::test]
async fn a_removal_notice_needs_the_communitys_operational_proof() {
use crate::operational::{OperationalError, SeenDocuments};
use crate::proof_check::{ProofError, Purpose, test_support::sign_for};
let resolver = test_resolver().await;
let vtc_key = did_key_secret(0x51);
let vtc = did_of_secret(&vtc_key);
let persona = "did:webvh:example:persona";
let acct = account_with_persona(&vtc, persona);
let mut seen = SeenDocuments::default();
let signers = [&vtc_key];
let auth = |doc| sign_for(doc, &signers, Purpose::Authentication);
macro_rules! run {
($m:expr, $from:expr) => {
verify_removal_notice(&$m, &$from, &acct, &resolver, &mut seen, Utc::now()).await
};
}
let signed = auth(notice_document(&vtc, persona)).await;
assert!(run!(notice_message(&vtc, signed.clone()), vtc).is_ok());
assert_eq!(
run!(notice_message(&vtc, signed.clone()), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::Replayed)
);
let stranger_key = did_key_secret(0x5a);
let stranger = did_of_secret(&stranger_key);
let theirs = sign_for(
notice_document(&stranger, persona),
&[&stranger_key],
Purpose::Authentication,
)
.await;
let rev = seen.revision();
assert_eq!(
run!(notice_message(&stranger, theirs), stranger).unwrap_err(),
RemovalNoticeError::NoMembership
);
assert_eq!(seen.revision(), rev, "nothing recorded for a stranger");
let asserted = sign_for(
notice_document(&vtc, persona),
&[&vtc_key],
Purpose::AssertionMethod,
)
.await;
assert_eq!(
run!(notice_message(&vtc, asserted), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::Proof(ProofError::WrongPurpose(0)))
);
assert_eq!(
run!(notice_message(&vtc, notice_document(&vtc, persona)), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::Proof(ProofError::NoProof))
);
let bare = notice_document(&vtc, persona)["payload"].clone();
assert!(run!(notice_message(&vtc, bare), vtc).is_err());
let mut tampered = auth(notice_document(&vtc, persona)).await;
tampered["payload"]["code"] = serde_json::json!("purged");
assert_eq!(
run!(notice_message(&vtc, tampered), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::Proof(ProofError::Invalid(0)))
);
let other = did_key_secret(0x52);
let forged = sign_for(
notice_document(&vtc, persona),
&[&other],
Purpose::Authentication,
)
.await;
assert_eq!(
run!(notice_message(&vtc, forged), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::Proof(
ProofError::ForeignVerificationMethod(0)
))
);
let elsewhere = did_of_secret(&other);
let fresh = auth(notice_document(&vtc, persona)).await;
assert_eq!(
run!(notice_message(&elsewhere, fresh), elsewhere).unwrap_err(),
RemovalNoticeError::Document(OperationalError::IssuerNotSender)
);
let mut unaddressed = notice_document(&vtc, persona);
unaddressed.as_object_mut().unwrap().remove("recipient");
let unaddressed = auth(unaddressed).await;
assert_eq!(
run!(notice_message(&vtc, unaddressed), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::NoRecipient)
);
let mut misaddressed = notice_document(&vtc, persona);
misaddressed["recipient"] = serde_json::json!("did:webvh:example:someone-else");
let misaddressed = auth(misaddressed).await;
assert_eq!(
run!(notice_message(&vtc, misaddressed), vtc).unwrap_err(),
RemovalNoticeError::WrongRecipient
);
let mut stale = notice_document(&vtc, persona);
stale["issuedAt"] =
serde_json::json!((Utc::now() - chrono::TimeDelta::days(40)).to_rfc3339());
let stale = auth(stale).await;
assert_eq!(
run!(notice_message(&vtc, stale), vtc).unwrap_err(),
RemovalNoticeError::Document(OperationalError::TooOld)
);
}
#[test]
fn removal_notice_removes_active_member_with_evidence() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
acct.memberships_mut().next().unwrap().activate(Utc::now());
let out = handle_member_removal_notice(
&mut acct,
removal_notice(
vtc,
serde_json::json!({
"did": persona,
"code": "adminRemoved",
"disposition": "tombstone",
"reason": "code of conduct",
"decidedAt": "2026-08-23T09:14:02Z",
"decidedBy": "did:key:z6MkAdmin",
}),
),
vtc,
);
assert!(out.changed);
assert!(
out.inactivated.is_some(),
"a removal deregisters the session, like a rejection"
);
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Removed));
assert!(rec.needs_attention(), "a fresh Removed nags (R-S-2)");
let d = rec.decision.clone().expect("removal records evidence");
assert_eq!(d.code.as_deref(), Some("adminRemoved"));
assert_eq!(d.reason.as_deref(), Some("code of conduct"));
assert_eq!(d.decided_by.as_deref(), Some("did:key:z6MkAdmin"));
assert_eq!(d.disposition.as_deref(), Some("tombstone"));
assert!(d.decided_at.is_some());
}
#[test]
fn removal_notice_without_reason_stays_absent() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
acct.memberships_mut().next().unwrap().activate(Utc::now());
handle_member_removal_notice(
&mut acct,
removal_notice(
vtc,
serde_json::json!({
"did": persona,
"code": "purged",
"disposition": "purge",
"decidedAt": "2026-08-23T11:02:41Z",
"decidedBy": "did:key:z6MkSuperAdmin",
}),
),
vtc,
);
let d = only(&acct, vtc).decision.clone().expect("records evidence");
assert!(d.reason.is_none(), "an omitted reason stays absent");
assert_eq!(d.code.as_deref(), Some("purged"));
}
#[test]
fn removal_notice_for_unknown_persona_is_ignored() {
let vtc = "did:webvh:example:vtc";
let mut acct = account_with_persona(vtc, "did:webvh:example:persona");
acct.memberships_mut().next().unwrap().activate(Utc::now());
let out = handle_member_removal_notice(
&mut acct,
removal_notice(
vtc,
serde_json::json!({
"did": "did:webvh:example:someone-else",
"code": "adminRemoved",
"disposition": "tombstone",
"decidedAt": "2026-08-23T09:14:02Z",
"decidedBy": "did:key:z6MkAdmin",
}),
),
vtc,
);
assert!(!out.changed);
assert!(matches!(only(&acct, vtc).status, CommunityStatus::Active));
}
#[test]
fn removal_notice_for_non_active_membership_is_ignored() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
acct.memberships_mut().next().unwrap().leave();
let out = handle_member_removal_notice(
&mut acct,
removal_notice(
vtc,
serde_json::json!({
"did": persona,
"code": "adminRemoved",
"disposition": "tombstone",
"decidedAt": "2026-08-23T09:14:02Z",
"decidedBy": "did:key:z6MkAdmin",
}),
),
vtc,
);
assert!(!out.changed);
assert!(
matches!(only(&acct, vtc).status, CommunityStatus::Left),
"the member's own Left is not overwritten"
);
}
#[test]
fn status_response_withdrawn_inactivates_without_badge() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out =
handle_join_status_response(&mut acct, &status_response(vtc, rid, "withdrawn"), vtc);
assert!(out.changed);
assert!(
out.inactivated.is_some(),
"a withdrawal must deregister the session (R-S-3)"
);
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Withdrawn));
assert!(
!rec.needs_attention(),
"a voluntary withdrawal never nags (like Left)"
);
}
#[test]
fn status_response_deferred_stays_pending() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out =
handle_join_status_response(&mut acct, &status_response(vtc, rid, "deferred"), vtc);
assert!(
out.changed,
"deferred is an acknowledgement — stamps receipt_at, needs persisting"
);
assert!(out.inactivated.is_none());
assert!(
only(&acct, vtc).receipt_at.is_some(),
"the VTC responded — acknowledged"
);
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
}
fn verdict(thid: &str, from: &str, effect: &str, with: serde_json::Value) -> Message {
Message::build(
Uuid::new_v4().to_string(),
vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RESPONSE_TYPE.to_string(),
serde_json::json!({
"requestId": Uuid::new_v4(),
"verdict": { "effect": effect, "with": with },
}),
)
.from(from.to_string())
.thid(thid.to_string())
.finalize()
}
fn verdict_document(thid: &str, from: &str, effect: &str, with: serde_json::Value) -> Message {
Message::build(
Uuid::new_v4().to_string(),
vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RESPONSE_TYPE.to_string(),
serde_json::json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"threadId": thid,
"type": vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RESPONSE_TYPE,
"issuer": from,
"payload": {
"requestId": Uuid::new_v4(),
"verdict": { "effect": effect, "with": with },
},
}),
)
.from(from.to_string())
.thid(thid.to_string())
.finalize()
}
#[test]
fn verdict_allow_is_read_from_response_document() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_verdict(
&mut acct,
&verdict_document(&rid.to_string(), vtc, "allow", serde_json::json!({})),
vtc,
);
assert!(
out.changed,
"an allow verdict in its wire form (payload nested in the #response \
document) is read and acknowledged"
);
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
}
#[test]
fn verdict_allow_awaits_the_credential() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_verdict(
&mut acct,
&verdict(&rid.to_string(), vtc, "allow", serde_json::json!({})),
vtc,
);
assert!(out.changed);
assert!(out.inactivated.is_none());
assert!(!only(&acct, vtc).status.is_active());
}
#[test]
fn verdict_deny_rejects_and_inactivates() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let with = serde_json::json!({ "code": "policy.denied", "reason": "no" });
let out = handle_join_verdict(
&mut acct,
&verdict(&rid.to_string(), vtc, "deny", with),
vtc,
);
assert!(out.changed);
assert!(out.inactivated.is_some(), "a deny deregisters the session");
assert!(matches!(only(&acct, vtc).status, CommunityStatus::Rejected));
}
#[test]
fn verdict_request_more_stays_pending() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let with = serde_json::json!({ "needs": ["proof-of-age"] });
let out = handle_join_verdict(
&mut acct,
&verdict(&rid.to_string(), vtc, "request_more", with),
vtc,
);
assert!(out.changed, "request_more acknowledges — stamps receipt_at");
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Pending { .. }));
assert!(rec.receipt_at.is_some(), "the VTC responded — acknowledged");
}
#[test]
fn a_verdict_that_stays_pending_adopts_the_communitys_request_id() {
for effect in ["refer", "request_more"] {
let vtc = "did:webvh:example:vtc";
let placeholder = Uuid::new_v4();
let vtc_request_id = Uuid::new_v4();
let mut acct = pending_account(vtc, placeholder);
assert!(
!only(&acct, vtc).request_id_confirmed,
"{effect}: before any reply we hold our own id, which the VTC has never seen"
);
assert!(only(&acct, vtc).is_pollable_pending());
let message = Message::build(
Uuid::new_v4().to_string(),
vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RESPONSE_TYPE.to_string(),
serde_json::json!({
"requestId": vtc_request_id,
"verdict": { "effect": effect, "with": {} },
}),
)
.from(vtc.to_string())
.thid(placeholder.to_string())
.finalize();
let out = handle_join_verdict(&mut acct, &message, vtc);
assert!(
out.changed,
"{effect}: adopting the id is a change to persist"
);
let rec = only(&acct, vtc);
match &rec.status {
CommunityStatus::Pending { request_id } => assert_eq!(
*request_id, vtc_request_id,
"{effect}: the record now names the join the way the community does"
),
other => panic!("{effect}: expected still-Pending, got {other:?}"),
}
assert!(
rec.is_pollable_pending(),
"{effect}: with the community's id, this join can now be asked about"
);
}
}
#[test]
fn a_submit_receipt_makes_the_join_pollable() {
let vtc = "did:webvh:example:vtc";
let placeholder = Uuid::new_v4();
let real = Uuid::new_v4();
let mut acct = pending_account(vtc, placeholder);
let message = Message::build(
Uuid::new_v4().to_string(),
vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RECEIPT_TYPE.to_string(),
serde_json::json!({ "requestId": real, "status": "pending" }),
)
.from(vtc.to_string())
.thid(placeholder.to_string())
.finalize();
assert!(handle_join_submit_receipt(&mut acct, &message, vtc));
assert!(only(&acct, vtc).is_pollable_pending());
}
#[test]
fn verdict_with_mismatched_thid_is_ignored() {
let vtc = "did:webvh:example:vtc";
let mut acct = pending_account(vtc, Uuid::new_v4());
let out = handle_join_verdict(
&mut acct,
&verdict(
&Uuid::new_v4().to_string(),
vtc,
"deny",
serde_json::json!({}),
),
vtc,
);
assert!(!out.changed);
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
}
#[test]
fn reply_routes_to_the_matching_membership_only() {
let vtc = "did:webvh:example:vtc";
let (rid_a, rid_b) = (Uuid::new_v4(), Uuid::new_v4());
let (pa, pb) = (PersonaId::new(), PersonaId::new());
let mut acct = Account::default();
acct.add_membership(CommunityRecord::new_pending(
vtc.into(),
None,
"openvtc/x".into(),
pa,
rid_a,
Utc::now(),
));
acct.add_membership(CommunityRecord::new_pending(
vtc.into(),
None,
"openvtc/x".into(),
pb,
rid_b,
Utc::now(),
));
let out = handle_join_verdict(
&mut acct,
&verdict(&rid_a.to_string(), vtc, "deny", serde_json::json!({})),
vtc,
);
assert!(out.changed);
assert!(
matches!(
acct.membership(vtc, pa).unwrap().status,
CommunityStatus::Rejected
),
"the membership whose request id matched is transitioned"
);
assert!(
matches!(
acct.membership(vtc, pb).unwrap().status,
CommunityStatus::Pending { .. }
),
"the other persona's membership is untouched"
);
}
fn trust_task_error(thid: &str, from: &str, code: &str, message: &str) -> Message {
Message::build(
Uuid::new_v4().to_string(),
format!("{}0.2", TRUST_TASK_ERROR_TYPE_PREFIX),
serde_json::json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"type": format!("{}0.2", TRUST_TASK_ERROR_TYPE_PREFIX),
"payload": { "code": code, "message": message },
}),
)
.from(from.to_string())
.thid(thid.to_string())
.finalize()
}
#[test]
fn trust_task_error_type_matches_any_version() {
assert!(is_trust_task_error_type(
"https://trusttasks.org/spec/trust-task-error/0.1"
));
assert!(is_trust_task_error_type(
"https://trusttasks.org/spec/trust-task-error/0.2"
));
assert!(!is_trust_task_error_type(
"https://trusttasks.org/spec/vtc/join-requests/submit/0.1"
));
}
#[test]
fn trust_task_error_denial_rejects_and_inactivates() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_trust_task_error(
&mut acct,
&trust_task_error(&rid.to_string(), vtc, "permissionDenied", "not allowed"),
vtc,
);
assert!(out.changed);
assert!(
out.inactivated.is_some(),
"a denial deregisters the session"
);
assert!(matches!(only(&acct, vtc).status, CommunityStatus::Rejected));
}
#[test]
fn a_v0_3_submit_refusal_ends_the_join_with_a_reason() {
use trust_tasks_rs::specs::vtc::join_requests::submit::v0_3::error_codes;
for (code, says) in [
(
error_codes::NOT_ACCEPTING.code,
"not accepting applications",
),
(error_codes::CRITERION_UNKNOWN.code, "join again"),
] {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_trust_task_error(
&mut acct,
&trust_task_error(&rid.to_string(), vtc, code, ""),
vtc,
);
assert!(out.inactivated.is_some(), "{code} ends the join");
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Rejected), "{code}");
let reason = rec
.decision
.as_ref()
.and_then(|d| d.reason.clone())
.unwrap_or_default();
assert!(reason.contains(says), "{code}: {reason}");
}
}
#[test]
fn a_request_more_says_what_the_join_lacks() {
let said = describe_join_needs(&[
"invitation".to_string(),
"vetting:independence".to_string(),
"vetting:minStatements".to_string(),
"somethingNew".to_string(),
]);
assert!(said.contains("an invitation"), "{said}");
assert!(said.contains("independent"), "{said}");
assert!(said.contains("more vetting (minStatements)"), "{said}");
assert!(said.contains("somethingNew"), "{said}");
assert!(describe_join_needs(&[]).contains("did not say"));
}
#[test]
fn only_a_request_more_verdict_has_needs() {
let vtc = "did:webvh:example:vtc";
let more = verdict(
"t",
vtc,
"request_more",
json!({ "needs": ["invitation"], "presentationDefinition": {} }),
);
assert_eq!(
join_verdict_needs(&more),
Some(vec!["invitation".to_string()])
);
assert_eq!(
join_verdict_needs(&verdict("t", vtc, "allow", json!({}))),
None
);
}
#[test]
fn trust_task_error_malformed_stays_pending_recoverable() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
let out = handle_join_trust_task_error(
&mut acct,
&trust_task_error(
&rid.to_string(),
vtc,
"malformedRequest",
"missing field `id`",
),
vtc,
);
assert!(
out.changed,
"the VTC responded — stamps receipt_at, needs persisting"
);
assert!(
out.inactivated.is_none(),
"recoverable — no terminal transition"
);
let rec = only(&acct, vtc);
assert!(
matches!(rec.status, CommunityStatus::Pending { .. }),
"stays Pending so a corrected retry can succeed"
);
assert!(
rec.receipt_at.is_some(),
"a trust-task-error is still an acknowledgement the submit arrived"
);
}
#[test]
fn trust_task_error_with_mismatched_thid_is_ignored() {
let vtc = "did:webvh:example:vtc";
let mut acct = pending_account(vtc, Uuid::new_v4());
let out = handle_join_trust_task_error(
&mut acct,
&trust_task_error(&Uuid::new_v4().to_string(), vtc, "permissionDenied", ""),
vtc,
);
assert!(!out.changed);
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
}
fn problem_report(thid: &str, from: &str, code: &str, comment: &str) -> Message {
Message::build(
Uuid::new_v4().to_string(),
vta_sdk::protocols::PROBLEM_REPORT_TYPE.to_string(),
serde_json::json!({ "code": code, "comment": comment }),
)
.from(from.to_string())
.thid(thid.to_string())
.finalize()
}
#[test]
fn a_problem_report_is_read_but_never_acted_on() {
let vtc = "did:webvh:example:vtc";
let rid = Uuid::new_v4();
let acct = pending_account(vtc, rid);
let note = read_problem_report(
&acct,
&problem_report(
&rid.to_string(),
vtc,
"e.p.msg.forbidden",
"invitation rejected",
),
vtc,
)
.expect("our community's report is read");
assert_eq!(note.code, "e.p.msg.forbidden");
assert_eq!(note.comment, "invitation rejected");
assert!(note.on_pending_join);
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
let unrelated = read_problem_report(
&acct,
&problem_report(&Uuid::new_v4().to_string(), vtc, "e.p.msg.bad-request", "x"),
vtc,
)
.unwrap();
assert!(!unrelated.on_pending_join);
}
#[test]
fn join_failures_from_another_party_change_nothing() {
let vtc = "did:webvh:example:vtc";
let mallory = "did:webvh:example:mallory";
let rid = Uuid::new_v4();
let mut acct = pending_account(vtc, rid);
assert!(
read_problem_report(
&acct,
&problem_report(&rid.to_string(), mallory, "e.p.msg.forbidden", "no"),
mallory,
)
.is_none(),
"a stranger's report is not even read"
);
let out = handle_join_trust_task_error(
&mut acct,
&trust_task_error(&rid.to_string(), mallory, "permissionDenied", "no"),
mallory,
);
assert!(!out.changed && out.inactivated.is_none());
assert!(!handle_join_submit_receipt(
&mut acct,
&receipt(&rid.to_string(), mallory, Uuid::new_v4(), "received"),
mallory,
));
let rec = only(&acct, vtc);
assert!(matches!(rec.status, CommunityStatus::Pending { .. }));
assert!(rec.receipt_at.is_none(), "not even acknowledged");
}
#[test]
fn status_response_with_mismatched_request_id_is_ignored() {
let vtc = "did:webvh:example:vtc";
let mut acct = pending_account(vtc, Uuid::new_v4());
let out = handle_join_status_response(
&mut acct,
&status_response(vtc, Uuid::new_v4(), "rejected"),
vtc,
);
assert!(!out.changed);
assert!(out.inactivated.is_none());
assert!(matches!(
only(&acct, vtc).status,
CommunityStatus::Pending { .. }
));
}
#[test]
fn status_response_from_unknown_community_is_ignored() {
let mut acct = pending_account("did:webvh:example:vtc", Uuid::new_v4());
let out = handle_join_status_response(
&mut acct,
&status_response("did:webvh:example:other", Uuid::new_v4(), "approved"),
"did:webvh:example:other",
);
assert!(!out.changed);
}
fn account_with_persona(vtc: &str, persona_did: &str) -> Account {
let mut acct = Account::default();
let pid = PersonaId::new();
acct.personas.insert(
pid,
PersonaRecord {
extra: serde_json::Map::new(),
persona_id: pid,
did: persona_did.to_string(),
did_document: None,
key_refs: Vec::new(),
mediator_did: None,
origin_context_id: String::new(),
created_at: Utc::now(),
label: None,
},
);
acct.add_membership(CommunityRecord::new_pending(
vtc.to_string(),
None,
"openvtc/x".to_string(),
pid,
Uuid::new_v4(),
Utc::now(),
));
acct
}
fn issue(from: &str, credential: serde_json::Value) -> Message {
enveloped_issue_message(from, from, &credential)
}
fn enveloped_issue_message(
from: &str,
issuer: &str,
credential: &serde_json::Value,
) -> Message {
Message::build(
Uuid::new_v4().to_string(),
CREDENTIAL_ISSUE_TYPE.to_string(),
serde_json::json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"type": CREDENTIAL_ISSUE_TYPE,
"issuer": issuer,
"recipient": "did:example:persona",
"payload": { "credential_response": { "credential": credential } },
"proof": { "type": "DataIntegrityProof" },
}),
)
.from(from.to_string())
.finalize()
}
#[test]
fn the_credential_is_read_from_a_pushed_issue_document() {
let credential = vc(
&["VerifiableCredential"],
"did:example:vtc",
"did:example:p",
);
let m = enveloped_issue_message("did:example:vtc", "did:example:vtc", &credential);
assert_eq!(credential_in_issue(&m), Some(credential));
}
#[test]
fn an_issue_document_from_someone_other_than_its_issuer_is_not_read() {
let credential = vc(
&["VerifiableCredential"],
"did:example:vtc",
"did:example:p",
);
let m = enveloped_issue_message("did:example:relay", "did:example:vtc", &credential);
assert_eq!(credential_in_issue(&m), None);
}
#[test]
fn a_bare_issue_body_is_not_read() {
let credential = vc(
&["VerifiableCredential"],
"did:example:vtc",
"did:example:p",
);
let m = Message::build(
Uuid::new_v4().to_string(),
CREDENTIAL_ISSUE_TYPE.to_string(),
serde_json::json!({ "credential_response": { "credential": credential } }),
)
.from("did:example:vtc".to_string())
.finalize();
assert_eq!(credential_in_issue(&m), None);
}
fn verified(m: &Message) -> VerifiedIssuedCredential {
VerifiedIssuedCredential::assume_verified(credential_in_issue(m).expect("a credential"))
}
fn vc(types: &[&str], issuer: &str, subject: &str) -> serde_json::Value {
match types.last().copied() {
Some("MembershipCredential") => crate::dtg::fixtures::grant(issuer, subject),
Some("AuthorityCredential") => {
crate::dtg::fixtures::role_vac(issuer, subject, "member")
}
Some("StatementCredential") => crate::dtg::fixtures::community_vetting(issuer, subject),
_ => serde_json::json!({
"type": types,
"issuer": issuer,
"credentialSubject": { "id": subject },
}),
}
}
#[test]
fn admission_reports_the_join_request_it_closed() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let pending_id = match only(&acct, vtc).status {
crate::config::account::CommunityStatus::Pending { request_id } => request_id,
ref other => panic!("fixture should start Pending, got {other:?}"),
};
let persona_id = only(&acct, vtc).persona_ref;
let m = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
vtc,
persona,
),
);
let outcome = handle_credential_issue(&mut acct, verified(&m), vtc);
assert_eq!(
outcome.closed_join,
Some((persona_id, pending_id)),
"the outcome must carry the request id and the persona that owes the reciprocal"
);
assert!(only(&acct, vtc).status.is_active());
}
#[test]
fn a_credential_that_admits_nobody_closes_no_join() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let vmc = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
vtc,
persona,
),
);
assert!(
handle_credential_issue(&mut acct, verified(&vmc), vtc)
.closed_join
.is_some()
);
let again = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
vtc,
persona,
),
);
let outcome = handle_credential_issue(&mut acct, verified(&again), vtc);
assert!(outcome.changed, "the credential is still stored");
assert_eq!(
outcome.closed_join, None,
"an already-active membership has no open join to close"
);
}
#[test]
fn credential_issue_vmc_activates_and_stores() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let m = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
vtc,
persona,
),
);
assert!(handle_credential_issue(&mut acct, verified(&m), vtc).changed);
let rec = only(&acct, vtc);
assert!(rec.status.is_active());
assert!(
rec.credentials
.contains_key(&crate::CredentialKind::Membership)
);
}
#[test]
fn credential_issue_role_vac_stores_without_activating() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let m = issue(
vtc,
vc(
&["VerifiableCredential", "AuthorityCredential"],
vtc,
persona,
),
);
assert!(handle_credential_issue(&mut acct, verified(&m), vtc).changed);
let rec = only(&acct, vtc);
assert!(
!rec.status.is_active(),
"role VAC must not activate on its own"
);
assert!(rec.credentials.contains_key(&crate::CredentialKind::Role));
}
#[test]
fn a_pre_v1_role_endorsement_is_not_stored() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let m = issue(
vtc,
crate::dtg::fixtures::retired_role_endorsement(vtc, persona),
);
assert!(!handle_credential_issue(&mut acct, verified(&m), vtc).changed);
assert!(only(&acct, vtc).credentials.is_empty());
}
#[test]
fn credential_issue_handles_every_registered_kind() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
for kind in crate::CredentialKind::ALL {
let mut acct = account_with_persona(vtc, persona);
let m = issue(
vtc,
vc(&["VerifiableCredential", kind.vc_type()], vtc, persona),
);
assert!(
handle_credential_issue(&mut acct, verified(&m), vtc).changed,
"kind {kind:?} should be accepted",
);
let rec = only(&acct, vtc);
assert!(
rec.credentials.contains_key(kind),
"kind {kind:?} should be stored under its registry key",
);
assert_eq!(
rec.status.is_active(),
kind.activates_membership(),
"activation for {kind:?} must match the registry",
);
}
}
#[test]
fn the_communitys_own_vetting_statement_is_stored_and_does_not_activate() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let own = crate::dtg::fixtures::community_vetting(vtc, persona);
assert!(
handle_credential_issue(&mut acct, verified(&issue(vtc, own.clone())), vtc).changed
);
let rec = only(&acct, vtc);
assert_eq!(
rec.credentials
.get(&crate::CredentialKind::CommunityVetting),
Some(&own)
);
assert!(
!rec.status.is_active(),
"an identity check is not admission"
);
for (label, other) in [
(
"a vetter's value",
crate::dtg::fixtures::vetted_statement(
vtc,
dtg_credentials::IssuerScope::Public,
persona,
vtc,
true,
),
),
(
"another community's check",
crate::dtg::fixtures::vetted_statement(
vtc,
dtg_credentials::IssuerScope::Public,
persona,
"did:webvh:example:elsewhere",
false,
),
),
] {
assert_eq!(
credential_issue_admissible(&acct, &other, vtc).map(|(_, k)| k),
Err("it is of no known kind"),
"{label}"
);
}
}
#[test]
fn a_credential_does_not_revive_an_ended_membership() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
for end in ["left", "removed", "rejected", "withdrawn"] {
let mut acct = account_with_persona(vtc, persona);
{
let rec = acct.memberships_mut().next().unwrap();
match end {
"left" => {
rec.activate(Utc::now());
rec.leave();
}
"removed" => {
rec.activate(Utc::now());
rec.remove(DecisionEvidence::default());
}
"rejected" => rec.reject(DecisionEvidence::default()),
_ => {
rec.withdraw();
}
}
}
let before = only(&acct, vtc).status.clone();
let m = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
vtc,
persona,
),
);
let out = handle_credential_issue(&mut acct, verified(&m), vtc);
assert!(!out.changed && out.closed_join.is_none(), "{end}");
let rec = only(&acct, vtc);
assert_eq!(rec.status, before, "{end}: status unchanged");
assert!(rec.credentials.is_empty(), "{end}: nothing stored");
}
}
#[test]
fn credential_issue_from_wrong_issuer_is_ignored() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let m = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
"did:webvh:evil",
persona,
),
);
assert!(!handle_credential_issue(&mut acct, verified(&m), vtc).changed);
assert!(!only(&acct, vtc).status.is_active());
}
#[test]
fn credential_issue_for_wrong_subject_is_ignored() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let mut acct = account_with_persona(vtc, persona);
let m = issue(
vtc,
vc(
&["VerifiableCredential", "MembershipCredential"],
vtc,
"did:webvh:someone-else",
),
);
assert!(!handle_credential_issue(&mut acct, verified(&m), vtc).changed);
assert!(!only(&acct, vtc).status.is_active());
}
#[test]
fn a_credential_is_admissible_only_for_a_live_membership_with_its_sender() {
let vtc = "did:webvh:example:vtc";
let persona = "did:webvh:example:persona";
let acct = account_with_persona(vtc, persona);
let vmc = |issuer: &str, subject: &str| {
vc(
&["VerifiableCredential", "MembershipCredential"],
issuer,
subject,
)
};
assert!(credential_issue_admissible(&acct, &vmc(vtc, persona), vtc).is_ok());
let stranger = "did:webvh:example:stranger";
assert!(
credential_issue_admissible(&acct, &vmc(stranger, persona), stranger)
.unwrap_err()
.contains("no membership")
);
assert!(credential_issue_admissible(&acct, &vmc(stranger, persona), vtc).is_err());
assert!(credential_issue_admissible(&acct, &vmc(vtc, "did:webvh:other"), vtc).is_err());
assert!(
credential_issue_admissible(&acct, &vc(&["VerifiableCredential"], vtc, persona), vtc)
.is_err()
);
}
#[test]
fn validate_did_accepts_well_formed_dids() {
assert!(validate_did("did:web:example.com").is_ok());
assert!(validate_did("did:webvh:abcdef0123:example.com").is_ok());
assert!(validate_did("did:peer:2.Vz6Mk-something").is_ok());
assert!(validate_did("did:key:z6MkpzExampleKey").is_ok());
assert!(validate_did("did:web:example.com%3A8080:path").is_ok());
}
#[test]
fn validate_did_rejects_old_prefix_loophole() {
assert!(validate_did("did:").is_err());
assert!(validate_did("did:abc").is_err()); assert!(validate_did("did::abc").is_err()); assert!(validate_did("not-a-did").is_err());
assert!(validate_did("").is_err());
}
#[test]
fn validate_did_rejects_uppercase_method() {
assert!(validate_did("did:WEB:example.com").is_err());
}
#[test]
fn validate_did_rejects_msi_with_invalid_chars() {
assert!(validate_did("did:web:exam ple.com").is_err()); assert!(validate_did("did:web:exam\u{200E}ple.com").is_err()); }
#[test]
fn validate_did_rejects_oversized_multibyte_input_without_panicking() {
for (label, filler, scalar) in [
("2-byte", 63, '\u{0281}'), ("3-byte", 62, '\u{20AC}'), ("4-byte", 61, '\u{1F600}'), ] {
let did = format!("{}{scalar}", "x".repeat(filler));
assert!(did.len() > 64, "{label}: case must exceed the cap");
assert!(
validate_did(&did).is_err(),
"{label}: must reject, not panic"
);
}
let msi = format!("did:web:{}\u{1F600}", "x".repeat(60));
assert!(validate_did(&msi).is_err());
}
use crate::relationships::Relationship;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::dids::{DID, KeyType};
fn relationship(remote_p: &str, remote_r: &str, state: RelationshipState) -> Relationship {
Relationship {
task_id: Arc::new(Uuid::new_v4().to_string()),
our_did: Arc::new("did:webvh:example:us".to_string()),
remote_did: Arc::new(remote_r.to_string()),
remote_p_did: Arc::new(remote_p.to_string()),
created: Utc::now(),
state,
our_persona: None,
needs_reestablishment: false,
}
}
fn relationships_with(rel: &Relationship) -> Relationships {
let mut rels = Relationships::default();
let key = Arc::clone(&rel.remote_p_did);
rels.relationships.insert(key, rel.clone());
rels
}
fn unsigned_vrc(issuer: &str) -> DTGCredential {
DTGCredential::new_vrc(
issuer.to_string(),
dtg_credentials::IssuerScope::Pairwise,
"did:webvh:example:subject".to_string(),
Utc::now(),
None,
)
}
#[test]
fn vrc_issued_with_forged_issuer_is_dropped_and_tasks_untouched() {
let sender = Arc::new("did:webvh:example:honest-sender".to_string());
let rel = relationship(&sender, &sender, RelationshipState::Established);
let rels = relationships_with(&rel);
let mut tasks = Tasks::default();
let pending = Arc::new(Uuid::new_v4().to_string());
tasks.new_task(
&pending,
TaskType::VRCRequestOutbound {
remote_p_did: Arc::clone(&rel.remote_p_did),
},
);
let vrc = unsigned_vrc("did:web:ATTACKER_FORGED");
let result = vet_vrc_issued(&rels, &tasks, &vrc, &sender, Some(pending.as_str()));
assert!(result.is_err(), "forged issuer must be rejected");
assert!(
tasks.get_by_id(&pending).is_some(),
"pending task must survive a rejected VRC"
);
}
#[test]
fn vrc_issued_without_relationship_is_dropped() {
let sender = Arc::new("did:webvh:example:stranger".to_string());
let rels = Relationships::default();
let tasks = Tasks::default();
let vrc = unsigned_vrc(sender.as_str());
assert!(vet_vrc_issued(&rels, &tasks, &vrc, &sender, None).is_err());
}
#[test]
fn vrc_issued_from_non_established_relationship_is_dropped() {
let sender = Arc::new("did:webvh:example:half-shaken".to_string());
let rel = relationship(&sender, &sender, RelationshipState::RequestSent);
let rels = relationships_with(&rel);
let tasks = Tasks::default();
let vrc = unsigned_vrc(sender.as_str());
assert!(vet_vrc_issued(&rels, &tasks, &vrc, &sender, None).is_err());
}
#[test]
fn vrc_issued_thid_matching_unrelated_task_is_ignored() {
let sender = Arc::new("did:webvh:example:sender".to_string());
let rel = relationship(&sender, &sender, RelationshipState::Established);
let rels = relationships_with(&rel);
let mut tasks = Tasks::default();
let unrelated = Arc::new(Uuid::new_v4().to_string());
tasks.new_task(
&unrelated,
TaskType::RelationshipRequestOutbound {
to: Arc::new("did:webvh:example:third-party".to_string()),
},
);
let other_rel = relationship(
"did:webvh:example:other",
"did:webvh:example:other",
RelationshipState::Established,
);
let other_request = Arc::new(Uuid::new_v4().to_string());
tasks.new_task(
&other_request,
TaskType::VRCRequestOutbound {
remote_p_did: Arc::clone(&other_rel.remote_p_did),
},
);
let vrc = unsigned_vrc(sender.as_str());
for thid in [unrelated.as_str(), other_request.as_str()] {
let resolved = vet_vrc_issued(&rels, &tasks, &vrc, &sender, Some(thid))
.expect("message itself is acceptable");
assert!(
resolved.is_none(),
"thid pointing at an unrelated task must not resolve it"
);
}
assert!(tasks.get_by_id(&unrelated).is_some());
assert!(tasks.get_by_id(&other_request).is_some());
}
#[test]
fn vrc_issued_thid_resolves_our_matching_outbound_request() {
let sender_p = "did:webvh:example:sender";
let sender_r = "did:webvh:example:sender-rdid";
let from = Arc::new(sender_r.to_string());
let rel = relationship(sender_p, sender_r, RelationshipState::Established);
let rels = relationships_with(&rel);
let mut tasks = Tasks::default();
let request = Arc::new(Uuid::new_v4().to_string());
tasks.new_task(
&request,
TaskType::VRCRequestOutbound {
remote_p_did: Arc::clone(&rel.remote_p_did),
},
);
let vrc = unsigned_vrc(sender_r);
let resolved = vet_vrc_issued(&rels, &tasks, &vrc, &from, Some(request.as_str()))
.expect("legitimate VRC must pass vetting");
assert_eq!(resolved, Some(request));
}
#[test]
fn vrc_issued_under_the_senders_persona_did_is_refused() {
let sender_p = "did:webvh:example:sender-persona";
let sender_r = "did:peer:2.SENDER_RELATIONSHIP";
let from = Arc::new(sender_r.to_string());
let rel = relationship(sender_p, sender_r, RelationshipState::Established);
let rels = relationships_with(&rel);
let vrc = unsigned_vrc(sender_p);
let err = vet_vrc_issued(&rels, &Tasks::default(), &vrc, &from, None)
.expect_err("a persona-issued VRC must not pass vetting");
assert!(
err.contains("is not the DID the sender uses in this relationship"),
"unexpected rejection reason: {err}"
);
}
#[test]
fn vrc_issued_under_the_persona_passes_when_that_is_the_relationship_did() {
let sender_p = "did:webvh:example:sender-persona";
let from = Arc::new(sender_p.to_string());
let rel = relationship(sender_p, sender_p, RelationshipState::Established);
let rels = relationships_with(&rel);
let vrc = unsigned_vrc(sender_p);
vet_vrc_issued(&rels, &Tasks::default(), &vrc, &from, None)
.expect("a relationship with no R-DID must still accept its persona-issued VRC");
}
async fn test_tdk() -> TDK {
TDK::new(
TDKConfig::builder()
.with_load_environment(false)
.build()
.expect("TDK config builds"),
None,
)
.await
.expect("TDK builds")
}
#[tokio::test]
async fn vrc_proof_validly_signed_credential_is_accepted() {
let tdk = test_tdk().await;
let (issuer_did, issuer_secret) =
DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
let mut vrc = unsigned_vrc(&issuer_did);
vrc.sign(&issuer_secret, None).await.expect("signs");
assert!(verify_vrc_proof(&tdk, &vrc).await.is_ok());
}
#[tokio::test]
async fn vrc_proof_tampered_credential_is_rejected() {
let tdk = test_tdk().await;
let (issuer_did, issuer_secret) =
DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
let mut vrc = unsigned_vrc(&issuer_did);
vrc.sign(&issuer_secret, None).await.expect("signs");
vrc.credential_mut()
.context
.push("https://attacker.example/context/v1".to_string());
assert!(verify_vrc_proof(&tdk, &vrc).await.is_err());
}
#[tokio::test]
async fn vrc_proof_signed_by_non_issuer_key_is_rejected() {
let tdk = test_tdk().await;
let (_attacker_did, attacker_secret) =
DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
let (victim_did, _victim_secret) =
DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
let mut vrc = unsigned_vrc(&victim_did);
vrc.sign(&attacker_secret, None).await.expect("signs");
assert!(verify_vrc_proof(&tdk, &vrc).await.is_err());
}
#[tokio::test]
async fn vrc_proof_for_another_purpose_is_rejected() {
let tdk = test_tdk().await;
let (issuer_did, issuer_secret) =
DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
let mut vrc = unsigned_vrc(&issuer_did);
vrc.sign(&issuer_secret, None).await.expect("signs");
if let Some(proof) = vrc.credential_mut().proof.as_mut() {
proof.proof_purpose = "authentication".to_string();
}
let error = verify_vrc_proof(&tdk, &vrc).await.unwrap_err();
assert!(error.contains("purpose"), "{error}");
}
#[tokio::test]
async fn vrc_proof_unsigned_credential_is_rejected() {
let tdk = test_tdk().await;
let vrc = unsigned_vrc("did:webvh:example:issuer");
assert!(verify_vrc_proof(&tdk, &vrc).await.is_err());
}
fn did_key_pubkey_bytes(did_key: &str) -> Vec<u8> {
let mb = did_key.strip_prefix("did:key:").expect("did:key prefix");
let (_base, decoded) = multibase::decode(mb).expect("multibase decode");
decoded[2..].to_vec()
}
#[tokio::test]
async fn vrc_proof_with_key_accepts_matching_key_rejects_others() {
let (issuer_did, issuer_secret) =
DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
let mut vrc = unsigned_vrc(&issuer_did);
vrc.sign(&issuer_secret, None).await.expect("signs");
assert!(verify_vrc_proof_with_key(&vrc, &did_key_pubkey_bytes(&issuer_did)).is_ok());
let (other_did, _) = DID::generate_did_key(KeyType::Ed25519).expect("did:key generates");
assert!(verify_vrc_proof_with_key(&vrc, &did_key_pubkey_bytes(&other_did)).is_err());
}
#[test]
fn vrc_proof_with_key_rejects_unsigned() {
let vrc = unsigned_vrc("did:webvh:example:issuer");
assert!(verify_vrc_proof_with_key(&vrc, &[0u8; 32]).is_err());
}
}