use serde_json::Value as JsonValue;
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use serde::Deserialize;
use vti_common::auth::TrustTaskVmResolver;
use crate::credentials::ingress::dtg_credential_digest_multibase;
use crate::credentials::trusted_witnesses::TrustedWitnesses;
use crate::relationships::Relationship;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum WitnessBinding {
Bound { relationship_id: Uuid },
SubjectMismatch { relationship_id: Uuid },
Unresolved,
Absent,
Malformed,
UntrustedWitness,
NoOutcomeEvidence,
OutcomeInvalid,
}
impl WitnessBinding {
pub fn is_bound(&self) -> bool {
matches!(self, Self::Bound { .. })
}
}
const SHA2_256_LEN: usize = 32;
fn decode_sha256_multihash(encoded: &str) -> Option<Vec<u8>> {
let (_algorithm, raw) = dtg_credentials::decode_digest_multibase(encoded).ok()?;
(raw.len() == SHA2_256_LEN).then_some(raw)
}
pub fn digests_match(asserted: &str, recomputed: &str) -> bool {
match (
decode_sha256_multihash(asserted),
decode_sha256_multihash(recomputed),
) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn statement_subject(claims: &JsonValue) -> &JsonValue {
match claims.get("credentialSubject") {
Some(subject) if subject.is_object() => subject,
_ => claims,
}
}
fn asserted_digest(claims: &JsonValue) -> Option<&str> {
statement_subject(claims)
.get("object")
.and_then(|object| object.get("digestMultibase"))
.and_then(JsonValue::as_str)
}
fn asserted_issuer(claims: &JsonValue) -> Option<&str> {
statement_subject(claims)
.get("id")
.and_then(JsonValue::as_str)
}
pub async fn resolve_binding(
relationships_ks: &KeyspaceHandle,
claims: &JsonValue,
) -> Result<WitnessBinding, AppError> {
let Some(asserted) = asserted_digest(claims) else {
return Ok(WitnessBinding::Absent);
};
if decode_sha256_multihash(asserted).is_none() {
return Ok(WitnessBinding::Malformed);
}
for rel in crate::relationships::storage::list_all(relationships_ks).await? {
if recomputes_to(&rel, asserted) {
return Ok(
if asserted_issuer(claims) == Some(rel.issuer_did.as_str()) {
WitnessBinding::Bound {
relationship_id: rel.id,
}
} else {
WitnessBinding::SubjectMismatch {
relationship_id: rel.id,
}
},
);
}
}
Ok(WitnessBinding::Unresolved)
}
pub fn is_witness_statement(credential: &JsonValue) -> bool {
let typed_as_statement = match credential.get("type") {
Some(JsonValue::String(t)) => t == STATEMENT_TYPE,
Some(JsonValue::Array(types)) => types.iter().any(|t| t.as_str() == Some(STATEMENT_TYPE)),
None => credential.get("credentialSubject").is_none(),
_ => false,
};
typed_as_statement
&& statement_subject(credential)
.get("predicate")
.and_then(JsonValue::as_str)
== Some(dtg_credentials::WITNESSED_V1)
}
const STATEMENT_TYPE: &str = vta_sdk::protocols::members::STATEMENT_CREDENTIAL_TYPE;
pub const WITNESS_BINDING_KEY: &str = "witness_binding";
pub async fn annotate_vp_claims(
ctx: &WitnessContext<'_>,
presentation: &JsonValue,
vp_claims: &mut JsonValue,
) {
let originals: Vec<JsonValue> = presentation
.get("verifiableCredential")
.and_then(JsonValue::as_array)
.map(|vcs| vcs.iter().filter(|v| v.is_object()).cloned().collect())
.unwrap_or_default();
let Some(credentials) = vp_claims
.get_mut("credentials")
.and_then(JsonValue::as_array_mut)
else {
return;
};
let aligned = originals.len() == credentials.len();
for (n, credential) in credentials.iter_mut().enumerate() {
let is_witness = is_witness_statement(credential);
let Some(entry) = credential.as_object_mut() else {
continue;
};
if !is_witness {
entry.remove(WITNESS_BINDING_KEY);
continue;
}
entry.remove(WITNESS_BINDING_KEY);
let snapshot = match originals.get(n).filter(|_| aligned) {
Some(original) => original.clone(),
None => JsonValue::Object(entry.clone()),
};
let issuer = credential_issuer(&snapshot).unwrap_or_default().to_string();
let binding = assess_or_unresolved(ctx, &issuer, &snapshot).await;
entry.insert(
WITNESS_BINDING_KEY.into(),
serde_json::to_value(&binding).expect("WitnessBinding serializes"),
);
}
}
pub fn credential_issuer(credential: &JsonValue) -> Option<&str> {
match credential.get("issuer")? {
JsonValue::String(s) => Some(s),
JsonValue::Object(o) => o.get("id").and_then(JsonValue::as_str),
_ => None,
}
}
pub struct WitnessContext<'a> {
pub relationships_ks: &'a KeyspaceHandle,
pub trusted: &'a TrustedWitnesses,
pub evidence: &'a [OutcomeEvidence],
pub resolver: &'a TrustTaskVmResolver,
}
pub async fn assess(
ctx: &WitnessContext<'_>,
issuer: &str,
credential: &JsonValue,
) -> Result<WitnessBinding, AppError> {
if !ctx.trusted.contains(issuer) {
return Ok(WitnessBinding::UntrustedWitness);
}
match pair_outcome(ctx.evidence, issuer, credential, ctx.resolver).await {
Pairing::Paired => {}
Pairing::Missing => return Ok(WitnessBinding::NoOutcomeEvidence),
Pairing::Invalid(reason) => {
tracing::warn!(
witness = %issuer,
%reason,
"a VWC's outcome evidence does not pair with it; it counts for nothing"
);
return Ok(WitnessBinding::OutcomeInvalid);
}
}
resolve_binding(ctx.relationships_ks, credential).await
}
pub async fn assess_or_unresolved(
ctx: &WitnessContext<'_>,
issuer: &str,
credential: &JsonValue,
) -> WitnessBinding {
assess(ctx, issuer, credential).await.unwrap_or_else(|e| {
tracing::warn!(error = %e, %issuer, "could not resolve witness digest binding");
WitnessBinding::Unresolved
})
}
pub const OUTCOME_EVIDENCE: &str = "outcomeEvidence";
pub const MAX_OUTCOME_EVIDENCE: usize = 16;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OutcomeEvidence {
pub session: JsonValue,
pub outcome: JsonValue,
}
pub fn outcome_evidence_from_payload(
payload: &JsonValue,
) -> Result<Vec<OutcomeEvidence>, AppError> {
let Some(list) = payload.get(OUTCOME_EVIDENCE) else {
return Ok(Vec::new());
};
let evidence: Vec<OutcomeEvidence> = serde_json::from_value(list.clone()).map_err(|e| {
AppError::Validation(format!(
"{OUTCOME_EVIDENCE} must be an array of {{ \"session\", \"outcome\" }} objects: {e}"
))
})?;
if evidence.is_empty() {
return Err(AppError::Validation(format!(
"{OUTCOME_EVIDENCE} is empty; omit it when no witness credential is to count"
)));
}
if evidence.len() > MAX_OUTCOME_EVIDENCE {
return Err(AppError::Validation(format!(
"{} outcome-evidence entries; at most {MAX_OUTCOME_EVIDENCE} may travel with one \
presentation",
evidence.len()
)));
}
if let Some(i) = evidence
.iter()
.position(|e| !is_document(&e.session) || !is_document(&e.outcome))
{
return Err(AppError::Validation(format!(
"{OUTCOME_EVIDENCE}[{i}]: session and outcome must each be a whole Trust Task document"
)));
}
Ok(evidence)
}
fn is_document(v: &JsonValue) -> bool {
v.as_object().is_some_and(|o| !o.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pairing {
Paired,
Missing,
Invalid(String),
}
fn declared_outcome_type(session_type: &str) -> Option<&'static str> {
use trust_tasks_rs::Payload;
use trust_tasks_rs::specs::witness::session;
if session_type == <session::v0_1::Payload as Payload>::TYPE_URI {
Some(<session::submit::v0_1::Response as Payload>::TYPE_URI)
} else if session_type == <session::v0_2::Payload as Payload>::TYPE_URI {
Some(<session::submit::v0_2::Response as Payload>::TYPE_URI)
} else {
None
}
}
pub async fn pair_outcome(
evidence: &[OutcomeEvidence],
issuer: &str,
vwc: &JsonValue,
resolver: &TrustTaskVmResolver,
) -> Pairing {
let Some(cited) = vwc.get("taskContext").and_then(JsonValue::as_str) else {
return Pairing::Missing;
};
let mut first_failure = None;
for entry in evidence
.iter()
.filter(|e| e.session.get("id").and_then(JsonValue::as_str) == Some(cited))
{
match check_pair(entry, issuer, vwc, resolver).await {
Ok(()) => return Pairing::Paired,
Err(reason) => {
first_failure.get_or_insert(reason);
}
}
}
match first_failure {
Some(reason) => Pairing::Invalid(reason),
None => Pairing::Missing,
}
}
fn str_at<'a>(doc: &'a JsonValue, key: &str) -> Option<&'a str> {
doc.get(key).and_then(JsonValue::as_str)
}
async fn check_pair(
entry: &OutcomeEvidence,
issuer: &str,
vwc: &JsonValue,
resolver: &TrustTaskVmResolver,
) -> Result<(), String> {
let session = &entry.session;
let outcome = &entry.outcome;
let declared = str_at(session, "type")
.and_then(declared_outcome_type)
.ok_or("the initiating document is not a witness/session")?;
let cited_digest =
str_at(vwc, "taskDigestMultibase").ok_or("the VWC carries no taskDigestMultibase")?;
let session_digest = dtg_credentials::task_digest_multibase_json(session)
.map_err(|e| format!("the session document will not canonicalize ({e})"))?;
if !digests_match(cited_digest, &session_digest) {
return Err("the VWC's taskDigestMultibase is not the session document's".into());
}
let thread = str_at(session, "threadId")
.or_else(|| str_at(session, "id"))
.ok_or("the session document has no id")?;
if str_at(outcome, "threadId") != Some(thread) {
return Err("the response is not on the session's thread".into());
}
if str_at(outcome, "type") != Some(declared) {
return Err(format!("the response is not {declared}"));
}
let recipient = str_at(session, "recipient")
.ok_or("the session document names no recipient, so no responder can be checked")?;
if recipient != issuer {
return Err("the session was addressed to someone other than the VWC's issuer".into());
}
if str_at(outcome, "issuer") != Some(recipient) {
return Err("the response is not issued by the session's witness".into());
}
let subject = statement_subject(vwc)
.get("id")
.and_then(JsonValue::as_str)
.ok_or("the VWC names no subject")?;
if str_at(session, "issuer") != Some(subject) {
return Err("the session was not opened by the VWC's subject".into());
}
if str_at(outcome, "recipient") != Some(subject) {
return Err("the response was not addressed to the VWC's subject".into());
}
let payload = outcome
.get("payload")
.ok_or("the response has no payload")?;
let delivered = payload
.get("vwc")
.filter(|v| v.is_object())
.ok_or("the response delivers no VWC")?;
let carried = str_at(payload, "vwcDigestMultibase")
.ok_or("the response carries no vwcDigestMultibase")?;
let delivered_digest = crate::credentials::ingress::digest_multibase(delivered)
.map_err(|e| format!("the delivered VWC will not canonicalize ({e})"))?;
if !digests_match(carried, &delivered_digest) {
return Err("vwcDigestMultibase is not the digest of the VWC delivered".into());
}
let same = match (
dtg_credential_digest_multibase(delivered),
dtg_credential_digest_multibase(vwc),
) {
(Ok(a), Ok(b)) => digests_match(&a, &b),
_ => false,
};
if !same {
return Err("the response delivered a different VWC from the one presented".into());
}
let signer = vti_common::auth::verify_trust_task_proof_value(outcome, resolver)
.await
.map_err(|e| format!("the response's proof does not verify ({e})"))?;
if signer != recipient {
return Err("the response is signed by someone other than the session's witness".into());
}
Ok(())
}
fn recomputes_to(rel: &Relationship, asserted: &str) -> bool {
match dtg_credential_digest_multibase(&rel.vrc_jsonld) {
Ok(recomputed) => digests_match(asserted, &recomputed),
Err(e) => {
tracing::warn!(
error = %e, relationship_id = %rel.id,
"stored VRC will not canonicalize; skipped for witness-digest matching"
);
false
}
}
}
#[cfg(test)]
pub(crate) mod fixtures {
use super::*;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
use chrono::Utc;
use dtg_credentials::{DTGCredential, IssuerScope};
use serde_json::json;
pub use vti_rooms_dtg::test_support::Party;
pub fn session_type() -> &'static str {
<trust_tasks_rs::specs::witness::session::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI
}
pub fn submit_response_type() -> &'static str {
<trust_tasks_rs::specs::witness::session::submit::v0_2::Response as trust_tasks_rs::Payload>::TYPE_URI
}
pub struct Witnessed {
pub witness: Party,
pub session: JsonValue,
pub vwc: JsonValue,
pub evidence: OutcomeEvidence,
}
pub fn session_document_typed(
party: &str,
counterparty: &str,
witness: &str,
type_uri: &str,
) -> JsonValue {
let id = format!("urn:uuid:{}", Uuid::new_v4());
json!({
"id": id,
"type": type_uri,
"threadId": id,
"issuer": party,
"recipient": witness,
"issuedAt": "2026-10-15T09:59:00Z",
"payload": { "parties": [party, counterparty] }
})
}
pub async fn outcome(witness: &Party, session: &JsonValue, vwc: &JsonValue) -> JsonValue {
outcome_typed(witness, session, vwc, submit_response_type()).await
}
pub async fn outcome_typed(
witness: &Party,
session: &JsonValue,
vwc: &JsonValue,
type_uri: &str,
) -> JsonValue {
let submitter = session["issuer"].as_str().unwrap();
let mut doc = vta_sdk::trust_task_sign::build_unsigned(
type_uri,
json!({
"vwc": vwc,
"vwcDigestMultibase": crate::credentials::ingress::digest_multibase(vwc).unwrap(),
}),
&witness.did,
submitter,
)
.expect("build the response");
doc.thread_id = session["threadId"].as_str().map(str::to_string);
let key = vta_sdk::trust_task_sign::HolderKey::from_did_key(
&witness.did,
&witness.secret_multibase,
)
.expect("a did:key names its own verification method");
vta_sdk::trust_task_sign::sign_in_place_with(&mut doc, &key)
.await
.expect("sign the response");
serde_json::to_value(&doc).unwrap()
}
pub async fn witnessed(edge: &JsonValue, counterparty: &str) -> Witnessed {
witnessed_with(edge, counterparty, |_| {}).await
}
pub async fn witnessed_with(
edge: &JsonValue,
counterparty: &str,
edit: impl FnOnce(&mut JsonValue),
) -> Witnessed {
witnessed_at(
edge,
counterparty,
session_type(),
submit_response_type(),
edit,
)
.await
}
pub async fn witnessed_at(
edge: &JsonValue,
counterparty: &str,
session_type: &str,
outcome_type: &str,
edit: impl FnOnce(&mut JsonValue),
) -> Witnessed {
let witness = Party::new();
let party = edge["issuer"].as_str().unwrap().to_string();
let session = session_document_typed(&party, counterparty, &witness.did, session_type);
let built = DTGCredential::new_witnessed_vsc(
witness.did.clone(),
IssuerScope::Public,
edge,
&session,
Utc::now(),
None,
None,
)
.expect("the opening witness/session document builds a VWC");
let mut vwc = crate::test_support::dtg_json(&built);
edit(&mut vwc);
let proof = DataIntegrityProof::sign(
&vwc,
&witness.secret,
SignOptions::new().with_proof_purpose("assertionMethod"),
)
.await
.expect("sign the VWC");
vwc["proof"] = serde_json::to_value(proof).unwrap();
let outcome = outcome_typed(&witness, &session, &vwc, outcome_type).await;
Witnessed {
witness,
evidence: OutcomeEvidence {
session: session.clone(),
outcome,
},
session,
vwc,
}
}
pub async fn trust(state: &crate::server::AppState, dids: &[&str]) {
let list: Vec<JsonValue> = dids.iter().map(|d| json!({ "did": d })).collect();
crate::config_store::ConfigStore::new(state.config_ks.clone())
.put(
crate::credentials::trusted_witnesses::WITNESSES_TRUSTED,
&JsonValue::Array(list),
)
.await
.expect("store the list");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn vrc() -> JsonValue {
json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": "did:webvh:issuer.example",
"credentialSubject": { "id": "did:webvh:subject.example" }
})
}
#[test]
fn the_same_digest_in_two_bases_matches() {
let b58 = dtg_credential_digest_multibase(&vrc()).unwrap();
let (_, bytes) = multibase::decode(&b58).unwrap();
let b16 = multibase::encode(multibase::Base::Base16Lower, &bytes);
assert_ne!(b58, b16, "the fixture must actually differ as strings");
assert!(digests_match(&b58, &b16));
assert!(digests_match(&b16, &b58), "and it is symmetric");
}
#[test]
fn different_documents_do_not_match() {
let a = dtg_credential_digest_multibase(&vrc()).unwrap();
let mut other = vrc();
other["issuer"] = json!("did:webvh:someone-else.example");
let b = dtg_credential_digest_multibase(&other).unwrap();
assert!(!digests_match(&a, &b));
}
#[test]
fn member_order_does_not_break_the_binding() {
let ordered = json!({ "a": 1, "b": { "x": 1, "y": 2 } });
let shuffled = json!({ "b": { "y": 2, "x": 1 }, "a": 1 });
assert!(digests_match(
&dtg_credential_digest_multibase(&ordered).unwrap(),
&dtg_credential_digest_multibase(&shuffled).unwrap()
));
}
#[test]
fn rejects_a_digest_that_is_not_sha2_256() {
let mut mh = vec![0x13, 0x40];
mh.extend_from_slice(&[7u8; 64]);
let encoded = multibase::encode(multibase::Base::Base58Btc, &mh);
assert!(decode_sha256_multihash(&encoded).is_none());
assert!(
!digests_match(&encoded, &encoded),
"not even against itself"
);
}
#[test]
fn rejects_a_bare_hash_with_no_multihash_header() {
let bare = multibase::encode(multibase::Base::Base58Btc, [9u8; 32]);
assert!(decode_sha256_multihash(&bare).is_none());
}
#[test]
fn rejects_text_that_is_not_multibase_at_all() {
assert!(decode_sha256_multihash("not-a-digest").is_none());
assert!(decode_sha256_multihash("").is_none());
}
#[test]
fn reads_the_digest_from_either_claim_shape() {
assert_eq!(
asserted_digest(
&json!({ "credentialSubject": { "object": { "digestMultibase": "zAbc" } } })
),
Some("zAbc")
);
assert_eq!(
asserted_digest(&json!({ "object": { "digestMultibase": "zAbc" } })),
Some("zAbc")
);
assert_eq!(asserted_digest(&json!({ "credentialSubject": {} })), None);
assert_eq!(asserted_digest(&json!({})), None);
}
#[test]
fn the_retired_digest_placement_is_not_read() {
assert_eq!(
asserted_digest(&json!({ "credentialSubject": { "digestMultibase": "zOld" } })),
None
);
}
#[test]
fn the_signed_location_wins() {
assert_eq!(
asserted_digest(&json!({
"credentialSubject": { "object": { "digestMultibase": "zSigned" } },
"object": { "digestMultibase": "zElsewhere" }
})),
Some("zSigned")
);
}
#[test]
fn rejects_a_truncated_sha2_256_digest() {
let mut mh = vec![0x12, 0x10];
mh.extend_from_slice(&[7u8; 16]);
let encoded = multibase::encode(multibase::Base::Base58Btc, &mh);
assert!(decode_sha256_multihash(&encoded).is_none());
}
#[test]
fn a_wd01_hex_digest_value_does_not_decode() {
assert!(decode_sha256_multihash(&format!("sha256:{}", "ab".repeat(32))).is_none());
}
#[test]
fn the_digest_excludes_the_top_level_proof() {
let mut signed = vrc();
signed["proof"] = json!({ "type": "DataIntegrityProof", "proofValue": "zSig" });
assert!(digests_match(
&dtg_credential_digest_multibase(&signed).unwrap(),
&dtg_credential_digest_multibase(&vrc()).unwrap()
));
}
use crate::relationships::Relationship;
use crate::relationships::storage::store_relationship;
fn vwc_claims(subject: &str, witnessed: &JsonValue) -> JsonValue {
json!({
"type": ["VerifiableCredential", "DTGCredential", "StatementCredential"],
"credentialSubject": {
"id": subject,
"predicate": dtg_credentials::WITNESSED_V1,
"object": {
"digestMultibase": dtg_credential_digest_multibase(witnessed).unwrap()
}
}
})
}
use chrono::Utc;
use uuid::Uuid;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn temp_kss() -> (KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let primary = store.keyspace("relationships").unwrap();
let index = store.keyspace("relationships_by_did").unwrap();
(primary, index, dir)
}
fn edge_with_a_stale_digest_column(issuer: &str, subject: &str) -> Relationship {
let id = Uuid::new_v4();
Relationship {
id,
issuer_did: issuer.into(),
subject_did: subject.into(),
vrc_jsonld: json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": issuer,
"credentialSubject": { "id": subject }
}),
vrc_digest_multibase: format!("{:x}", id.as_u128()),
created_at: Utc::now(),
persona: None,
lifecycle: Default::default(),
}
}
#[tokio::test]
async fn binds_a_witness_to_the_edge_its_digest_names() {
let (primary, index, _dir) = temp_kss().await;
let edge = edge_with_a_stale_digest_column("did:key:zIssuer", "did:key:zSubject");
let other = edge_with_a_stale_digest_column("did:key:zSomeone", "did:key:zElse");
store_relationship(&primary, &index, &edge).await.unwrap();
store_relationship(&primary, &index, &other).await.unwrap();
let vwc = vwc_claims("did:key:zIssuer", &edge.vrc_jsonld);
assert_eq!(
resolve_binding(&primary, &vwc).await.unwrap(),
WitnessBinding::Bound {
relationship_id: edge.id
},
"must name the edge it witnessed, not merely 'some edge'"
);
}
#[tokio::test]
async fn a_witness_naming_the_wrong_issuer_is_a_subject_mismatch() {
let (primary, index, _dir) = temp_kss().await;
let edge = edge_with_a_stale_digest_column("did:key:zIssuer", "did:key:zSubject");
store_relationship(&primary, &index, &edge).await.unwrap();
let vwc = vwc_claims("did:key:zSubject", &edge.vrc_jsonld);
assert_eq!(
resolve_binding(&primary, &vwc).await.unwrap(),
WitnessBinding::SubjectMismatch {
relationship_id: edge.id
}
);
}
#[tokio::test]
async fn a_foreign_base_encoding_still_binds() {
let (primary, index, _dir) = temp_kss().await;
let edge = edge_with_a_stale_digest_column("did:key:zIssuer", "did:key:zSubject");
store_relationship(&primary, &index, &edge).await.unwrap();
let (_, bytes) =
multibase::decode(dtg_credential_digest_multibase(&edge.vrc_jsonld).unwrap()).unwrap();
let mut vwc = vwc_claims("did:key:zIssuer", &edge.vrc_jsonld);
vwc["credentialSubject"]["object"]["digestMultibase"] =
json!(multibase::encode(multibase::Base::Base16Lower, &bytes));
assert_eq!(
resolve_binding(&primary, &vwc).await.unwrap(),
WitnessBinding::Bound {
relationship_id: edge.id
}
);
}
#[tokio::test]
async fn an_edge_this_service_does_not_hold_is_unresolved() {
let (primary, index, _dir) = temp_kss().await;
store_relationship(
&primary,
&index,
&edge_with_a_stale_digest_column("did:key:zA", "did:key:zB"),
)
.await
.unwrap();
let elsewhere = json!({ "type": ["VerifiableCredential"], "issuer": "did:key:zNowhere" });
let vwc = vwc_claims("did:key:zNowhere", &elsewhere);
assert_eq!(
resolve_binding(&primary, &vwc).await.unwrap(),
WitnessBinding::Unresolved
);
}
#[tokio::test]
async fn a_witness_asserting_no_digest_is_absent() {
let (primary, _index, _dir) = temp_kss().await;
assert_eq!(
resolve_binding(&primary, &json!({ "credentialSubject": {} }))
.await
.unwrap(),
WitnessBinding::Absent
);
}
#[tokio::test]
async fn an_undecodable_digest_is_malformed() {
let (primary, _index, _dir) = temp_kss().await;
for bad in ["not-a-digest", "", "zzzz!!!"] {
assert_eq!(
resolve_binding(
&primary,
&json!({ "credentialSubject": { "object": { "digestMultibase": bad } } })
)
.await
.unwrap(),
WitnessBinding::Malformed,
"{bad:?}"
);
}
}
#[tokio::test]
async fn an_empty_keyspace_binds_nothing() {
let (primary, _index, _dir) = temp_kss().await;
let vwc = vwc_claims("did:webvh:issuer.example", &vrc());
assert_eq!(
resolve_binding(&primary, &vwc).await.unwrap(),
WitnessBinding::Unresolved
);
}
use crate::test_support::dtg_json;
use dtg_credentials::{DTGCredential, IssuerScope};
const WITNESS: &str = "did:webvh:witness.example";
const ALICE: &str = "did:key:zAlice";
const BOB: &str = "did:key:zBob";
const SESSION: &str = "urn:uuid:6f1c1c1e-5a8b-4f7e-9d0c-2b7a4e1d9c30";
fn witness_session() -> JsonValue {
json!({
"id": SESSION,
"type": "https://trusttasks.org/spec/witness/session/0.2",
"threadId": SESSION,
"issuer": ALICE,
"recipient": WITNESS,
"issuedAt": "2026-09-22T09:59:00Z",
"payload": { "parties": [ALICE, BOB] }
})
}
fn stored_signed_vrc(issuer: &str, subject: &str) -> (DTGCredential, Relationship) {
let vrc = DTGCredential::new_vrc(
issuer.into(),
IssuerScope::Pairwise,
subject.into(),
Utc::now(),
None,
);
let mut vrc_jsonld = dtg_json(&vrc);
vrc_jsonld["proof"] = json!({
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": format!("{issuer}#key-0"),
"proofPurpose": "assertionMethod",
"proofValue": "z3FXQjecWufY46yg5abdVZsXqLhxhueuSoZgNSARiKBk"
});
let rel = Relationship {
id: Uuid::new_v4(),
issuer_did: issuer.into(),
subject_did: subject.into(),
vrc_digest_multibase: crate::credentials::ingress::digest_multibase(&vrc_jsonld)
.unwrap(),
vrc_jsonld,
created_at: Utc::now(),
persona: None,
lifecycle: Default::default(),
};
(vrc, rel)
}
fn vwc_for(witnessed: &DTGCredential) -> DTGCredential {
DTGCredential::new_witnessed_vsc(
WITNESS.into(),
IssuerScope::Directed,
&dtg_json(witnessed),
&witness_session(),
Utc::now(),
None,
None,
)
.expect("the opening witness/session document builds a VWC")
}
#[tokio::test]
async fn a_catalog_built_vwc_binds_to_the_signed_vrc_it_witnessed() {
let (primary, index, _dir) = temp_kss().await;
let (alice_to_bob, edge) = stored_signed_vrc(ALICE, BOB);
let (_, unrelated) = stored_signed_vrc("did:key:zCarol", "did:key:zDave");
store_relationship(&primary, &index, &unrelated)
.await
.unwrap();
store_relationship(&primary, &index, &edge).await.unwrap();
let vwc = dtg_json(&vwc_for(&alice_to_bob));
assert!(is_witness_statement(&vwc));
assert_eq!(vwc["credentialSubject"]["id"], ALICE);
assert_eq!(
vwc["credentialSubject"]["object"]["digestMultibase"]
.as_str()
.and_then(|d| d.chars().next()),
Some('z'),
"§Digest Encoding: issuers MUST use base58btc"
);
assert_eq!(
resolve_binding(&primary, &vwc).await.unwrap(),
WitnessBinding::Bound {
relationship_id: edge.id
}
);
}
#[test]
fn a_catalog_built_vwc_carries_the_required_task_context() {
let (alice_to_bob, _) = stored_signed_vrc(ALICE, BOB);
let built = vwc_for(&alice_to_bob);
let vwc = dtg_json(&built);
assert_eq!(vwc["taskContext"], SESSION);
assert_eq!(
vwc["taskDigestMultibase"],
dtg_credentials::task_digest_multibase_json(&witness_session()).unwrap(),
"the digest is the session document's, not something the issuer chose"
);
assert!(
built.cites_task(&witness_session()).unwrap(),
"both halves of the citation resolve back to the document they came from"
);
assert_eq!(
crate::credentials::ingress::classify_dtg(&vwc).unwrap(),
dtg_credentials::DTGCredentialType::Statement
);
assert_eq!(vwc["issuerScope"], "directed");
}
#[tokio::test]
async fn each_direction_of_a_witnessed_exchange_binds_its_own_edge() {
let (primary, index, _dir) = temp_kss().await;
let (a_to_b, edge_ab) = stored_signed_vrc(ALICE, BOB);
let (b_to_a, edge_ba) = stored_signed_vrc(BOB, ALICE);
store_relationship(&primary, &index, &edge_ab)
.await
.unwrap();
store_relationship(&primary, &index, &edge_ba)
.await
.unwrap();
for (vrc, edge) in [(&a_to_b, &edge_ab), (&b_to_a, &edge_ba)] {
assert_eq!(
resolve_binding(&primary, &dtg_json(&vwc_for(vrc)))
.await
.unwrap(),
WitnessBinding::Bound {
relationship_id: edge.id
}
);
}
}
#[tokio::test]
async fn annotation_puts_the_verdict_on_witness_entries_only() {
let (primary, index, _dir) = temp_kss().await;
let (_, edge) = stored_signed_vrc(ALICE, BOB);
store_relationship(&primary, &index, &edge).await.unwrap();
let w = fixtures::witnessed(&edge.vrc_jsonld, BOB).await;
let trusted = TrustedWitnesses::from_list(&[TrustedWitness {
did: w.witness.did.clone(),
label: None,
}]);
let resolver = TrustTaskVmResolver::did_key_only();
let evidence = [w.evidence.clone()];
let ctx = WitnessContext {
relationships_ks: &primary,
trusted: &trusted,
evidence: &evidence,
resolver: &resolver,
};
let vp = json!({
"holder": ALICE,
"verifiableCredential": [
w.vwc,
{ "type": ["VerifiableCredential"], "issuer": "did:key:zOther",
"credentialSubject": { "id": ALICE } }
]
});
let mut claims = crate::policy::extract::extract_vp_claims(&vp);
annotate_vp_claims(&ctx, &vp, &mut claims).await;
assert_eq!(
claims["credentials"][0][WITNESS_BINDING_KEY],
json!({ "state": "bound", "relationship_id": edge.id })
);
assert!(
claims["credentials"][1].get(WITNESS_BINDING_KEY).is_none(),
"a non-witness credential has no binding question to answer"
);
}
#[tokio::test]
async fn a_presenter_cannot_supply_the_verdict() {
let (primary, _index, _dir) = temp_kss().await;
let forged_verdict = json!({ "state": "bound", "relationship_id": Uuid::new_v4() });
let trusted = TrustedWitnesses::from_list(&[TrustedWitness {
did: WITNESS.into(),
label: None,
}]);
let resolver = TrustTaskVmResolver::did_key_only();
let ctx = WitnessContext {
relationships_ks: &primary,
trusted: &trusted,
evidence: &[],
resolver: &resolver,
};
let mut claims = json!({
"holder": BOB,
"credentials": [
{ "type": ["VerifiableCredential", "DTGCredential", "StatementCredential"],
"issuer": WITNESS,
"credentialSubject": {
"id": ALICE,
"predicate": dtg_credentials::WITNESSED_V1,
"object": { "id": "did:key:zNotADigest" }
},
"witness_binding": forged_verdict },
{ "type": ["VerifiableCredential"],
"issuer": WITNESS,
"witness_binding": forged_verdict }
]
});
annotate_vp_claims(&ctx, &JsonValue::Null, &mut claims).await;
assert_eq!(
claims["credentials"][0][WITNESS_BINDING_KEY],
json!({ "state": "noOutcomeEvidence" }),
"overwritten with what the credential actually establishes"
);
assert!(claims["credentials"][1].get(WITNESS_BINDING_KEY).is_none());
}
use crate::credentials::trusted_witnesses::TrustedWitness;
use fixtures::Witnessed;
async fn assess_with(
w: &Witnessed,
trusted: &[&str],
evidence: &[OutcomeEvidence],
) -> WitnessBinding {
let (primary, index, _dir) = temp_kss().await;
let edge = Relationship {
id: Uuid::new_v4(),
issuer_did: ALICE.into(),
subject_did: BOB.into(),
vrc_jsonld: witnessed_edge(),
vrc_digest_multibase: String::new(),
created_at: Utc::now(),
persona: None,
lifecycle: Default::default(),
};
store_relationship(&primary, &index, &edge).await.unwrap();
let list: Vec<TrustedWitness> = trusted
.iter()
.map(|d| TrustedWitness {
did: (*d).into(),
label: None,
})
.collect();
let trusted = TrustedWitnesses::from_list(&list);
let resolver = TrustTaskVmResolver::did_key_only();
let ctx = WitnessContext {
relationships_ks: &primary,
trusted: &trusted,
evidence,
resolver: &resolver,
};
assess(&ctx, &w.witness.did, &w.vwc).await.unwrap()
}
fn witnessed_edge() -> JsonValue {
json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": ALICE,
"credentialSubject": { "id": BOB }
})
}
#[tokio::test]
async fn a_listed_witness_with_paired_evidence_binds() {
let w = fixtures::witnessed(&witnessed_edge(), BOB).await;
let did = w.witness.did.clone();
let evidence = [w.evidence.clone()];
assert!(assess_with(&w, &[&did], &evidence).await.is_bound());
}
#[tokio::test]
async fn an_unlisted_witness_is_untrusted_whatever_else_holds() {
let w = fixtures::witnessed(&witnessed_edge(), BOB).await;
let evidence = [w.evidence.clone()];
assert_eq!(
assess_with(&w, &[], &evidence).await,
WitnessBinding::UntrustedWitness,
"the empty default"
);
assert_eq!(
assess_with(&w, &["did:key:z6MkSomeoneElse"], &evidence).await,
WitnessBinding::UntrustedWitness
);
}
#[tokio::test]
async fn a_listed_witness_without_evidence_has_none() {
let w = fixtures::witnessed(&witnessed_edge(), BOB).await;
let did = w.witness.did.clone();
assert_eq!(
assess_with(&w, &[&did], &[]).await,
WitnessBinding::NoOutcomeEvidence
);
let other = fixtures::witnessed(&witnessed_edge(), BOB).await;
assert_eq!(
assess_with(&w, &[&did], &[other.evidence]).await,
WitnessBinding::NoOutcomeEvidence
);
}
#[tokio::test]
async fn evidence_that_does_not_pair_is_invalid() {
let w = fixtures::witnessed(&witnessed_edge(), BOB).await;
let resolver = TrustTaskVmResolver::did_key_only();
let pair = |e: OutcomeEvidence| {
let vwc = w.vwc.clone();
let did = w.witness.did.clone();
let resolver = resolver.clone();
async move { pair_outcome(&[e], &did, &vwc, &resolver).await }
};
assert_eq!(pair(w.evidence.clone()).await, Pairing::Paired);
let resign = |outcome: &JsonValue| {
let mut unsigned = outcome.clone();
unsigned.as_object_mut().unwrap().remove("proof");
unsigned
};
let mut cases: Vec<(&str, OutcomeEvidence, &str)> = Vec::new();
let mut e = w.evidence.clone();
e.session["payload"]["parties"] = json!([ALICE, "did:key:zMallory"]);
cases.push(("tampered session", e, "taskDigestMultibase"));
let mut e = w.evidence.clone();
e.outcome["threadId"] = json!("urn:uuid:another-thread");
cases.push(("another thread", e, "thread"));
let mut e = w.evidence.clone();
e.outcome["type"] = json!(format!("{}#response", fixtures::session_type()));
cases.push(("wrong type", e, "is not"));
let forger = fixtures::Party::new();
let mut forged = fixtures::outcome(&forger, &w.session, &w.vwc).await;
forged["issuer"] = json!(w.witness.did);
cases.push((
"forged signature",
OutcomeEvidence {
session: w.session.clone(),
outcome: forged,
},
"proof",
));
let mut e = w.evidence.clone();
e.outcome["payload"]["vwcDigestMultibase"] =
json!(crate::credentials::ingress::digest_multibase(&json!({ "x": 1 })).unwrap());
cases.push(("wrong vwcDigestMultibase", e, "vwcDigestMultibase"));
let other = fixtures::witnessed(&witnessed_edge(), BOB).await;
let delivered_elsewhere = fixtures::outcome(&w.witness, &w.session, &other.vwc).await;
cases.push((
"another VWC",
OutcomeEvidence {
session: w.session.clone(),
outcome: delivered_elsewhere,
},
"different VWC",
));
cases.push((
"unsigned",
OutcomeEvidence {
session: w.session.clone(),
outcome: resign(&w.evidence.outcome),
},
"proof",
));
for (why, evidence, expect) in cases {
match pair(evidence).await {
Pairing::Invalid(reason) => {
assert!(reason.contains(expect), "{why}: {reason}")
}
other => panic!("{why}: expected Invalid, got {other:?}"),
}
}
}
#[tokio::test]
async fn a_session_with_another_witness_does_not_pair() {
let w = fixtures::witnessed(&witnessed_edge(), BOB).await;
let resolver = TrustTaskVmResolver::did_key_only();
assert!(matches!(
pair_outcome(
std::slice::from_ref(&w.evidence),
"did:key:z6MkAnotherWitness",
&w.vwc,
&resolver
)
.await,
Pairing::Invalid(_)
));
}
#[test]
fn the_payload_member_is_read_and_its_shape_is_enforced() {
let session = json!({ "id": "urn:uuid:s" });
let outcome = json!({ "type": "x" });
let payload = json!({ OUTCOME_EVIDENCE: [{ "session": session, "outcome": outcome }] });
let got = outcome_evidence_from_payload(&payload).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].session, session);
assert!(
outcome_evidence_from_payload(&json!({}))
.unwrap()
.is_empty()
);
for bad in [
json!({ OUTCOME_EVIDENCE: [] }),
json!({ OUTCOME_EVIDENCE: {} }),
json!({ OUTCOME_EVIDENCE: [{ "session": {} }] }),
json!({ OUTCOME_EVIDENCE: [{ "session": {}, "outcome": {} }] }),
json!({ OUTCOME_EVIDENCE: [{ "session": session, "outcome": outcome, "extra": 1 }] }),
] {
assert!(outcome_evidence_from_payload(&bad).is_err(), "{bad}");
}
let too_many: Vec<_> = (0..=MAX_OUTCOME_EVIDENCE)
.map(|_| json!({ "session": session, "outcome": outcome }))
.collect();
assert!(outcome_evidence_from_payload(&json!({ OUTCOME_EVIDENCE: too_many })).is_err());
}
#[test]
fn the_retired_ext_carriage_is_not_read() {
let payload = json!({ "ext": { "org.openvtc.witness": { OUTCOME_EVIDENCE: [
{ "session": { "id": "urn:uuid:s" }, "outcome": { "type": "x" } }
] } } });
assert!(outcome_evidence_from_payload(&payload).unwrap().is_empty());
}
#[tokio::test]
async fn the_terminal_type_follows_the_sessions_own_version() {
use trust_tasks_rs::Payload;
use trust_tasks_rs::specs::witness::session;
let resolver = TrustTaskVmResolver::did_key_only();
let v01 = <session::v0_1::Payload as Payload>::TYPE_URI;
let v01_out = <session::submit::v0_1::Response as Payload>::TYPE_URI;
let v02_out = fixtures::submit_response_type();
let w = fixtures::witnessed_at(&witnessed_edge(), BOB, v01, v01_out, |_| {}).await;
assert_eq!(
pair_outcome(
std::slice::from_ref(&w.evidence),
&w.witness.did,
&w.vwc,
&resolver
)
.await,
Pairing::Paired,
"a 0.1 session under its own declaration"
);
let crossed = fixtures::witnessed_at(&witnessed_edge(), BOB, v01, v02_out, |_| {}).await;
assert!(matches!(
pair_outcome(
std::slice::from_ref(&crossed.evidence),
&crossed.witness.did,
&crossed.vwc,
&resolver
)
.await,
Pairing::Invalid(reason) if reason.contains("is not")
));
let crossed = fixtures::witnessed_at(
&witnessed_edge(),
BOB,
fixtures::session_type(),
v01_out,
|_| {},
)
.await;
assert!(matches!(
pair_outcome(
std::slice::from_ref(&crossed.evidence),
&crossed.witness.did,
&crossed.vwc,
&resolver
)
.await,
Pairing::Invalid(_)
));
}
#[test]
fn a_witness_is_a_witnessed_statement() {
let subject = json!({ "id": ALICE, "predicate": dtg_credentials::WITNESSED_V1 });
assert!(is_witness_statement(
&json!({ "type": "StatementCredential", "credentialSubject": subject })
));
assert!(!is_witness_statement(&json!({
"type": ["VerifiableCredential", "StatementCredential"],
"credentialSubject": { "id": ALICE, "predicate": dtg_credentials::ENDORSES_V1 }
})));
assert!(!is_witness_statement(&json!({
"type": ["VerifiableCredential", "RelationshipCredential"],
"credentialSubject": subject
})));
assert!(!is_witness_statement(&json!({
"type": "StatementCredential",
"credentialSubject": { "predicate": "dtg:witnessed" }
})));
assert!(!is_witness_statement(&json!({})));
}
}