use serde_json::Value;
use vta_sdk::vp::{CandidateSet, HeldCredential, VpError, build_vp_token, select_credentials};
use crate::config::account::CommunityRecord;
const LDP_VC: &str = "ldp_vc";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisclosureItem {
pub credential_query_id: String,
pub kind: String,
pub issuer: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DisclosureRequest {
pub verifier_did: String,
pub purpose: String,
pub items: Vec<DisclosureItem>,
nonce: String,
selection: CandidateSet,
}
impl DisclosureRequest {
#[must_use]
pub fn discloses_whole_credentials(&self) -> bool {
true
}
}
#[derive(Debug, thiserror::Error)]
pub enum PresentationError {
#[error("no held credential satisfies the request: {0}")]
NoMatch(String),
#[error("could not build the presentation: {0}")]
Build(String),
}
#[must_use]
pub fn held_credentials(
membership: Option<&CommunityRecord>,
vrcs: &crate::vrc::Vrcs,
) -> Vec<HeldCredential> {
let mut held = Vec::new();
if let Some(community) = membership {
for (kind, credential) in &community.credentials {
held.push(HeldCredential {
id: format!("community:{}", kind.config_key()),
format: LDP_VC.to_string(),
claims: credential.clone(),
vct: None,
doctype: None,
supports_holder_binding: true,
vc: credential.clone(),
});
}
}
for per_remote in vrcs.values() {
for (vrc_id, credential) in per_remote {
let Ok(claims) = serde_json::to_value(credential.as_ref()) else {
continue;
};
held.push(HeldCredential {
id: format!("vrc:{vrc_id}"),
format: LDP_VC.to_string(),
claims: claims.clone(),
vct: None,
doctype: None,
supports_holder_binding: true,
vc: claims,
});
}
}
held
}
pub fn evaluate_query(
presentation_definition: &Value,
held: &[HeldCredential],
verifier_did: &str,
purpose: &str,
nonce: &str,
) -> Result<DisclosureRequest, PresentationError> {
let selection = select_credentials(presentation_definition, held)
.map_err(|e| PresentationError::NoMatch(e.to_string()))?;
let items = selection
.entries
.iter()
.map(|selected| DisclosureItem {
credential_query_id: selected.credential_query_id.clone(),
kind: credential_kind(&selected.credential),
issuer: issuer_of(&selected.credential.claims),
})
.collect();
Ok(DisclosureRequest {
verifier_did: verifier_did.to_string(),
purpose: purpose.to_string(),
items,
nonce: nonce.to_string(),
selection,
})
}
pub async fn present(
request: DisclosureRequest,
holder_signer: &affinidi_tdk::secrets_resolver::secrets::Secret,
audience: &str,
) -> Result<Value, PresentationError> {
build_vp_token(&request.selection, holder_signer, &request.nonce, audience)
.await
.map_err(|e: VpError| PresentationError::Build(e.to_string()))
}
fn credential_kind(credential: &HeldCredential) -> String {
credential
.claims
.get("type")
.and_then(Value::as_array)
.and_then(|types| {
types
.iter()
.filter_map(Value::as_str)
.find(|t| *t != "VerifiableCredential")
.map(ToString::to_string)
})
.unwrap_or_else(|| credential.id.clone())
}
fn issuer_of(claims: &Value) -> Option<String> {
match claims.get("issuer")? {
Value::String(did) => Some(did.clone()),
Value::Object(map) => map.get("id")?.as_str().map(ToString::to_string),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const VERIFIER: &str = "did:webvh:QmScidVtc:vtc.example:acme";
const ISSUER: &str = "did:webvh:QmScidIssuer:issuer.example:acme";
fn membership_vc() -> Value {
json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": ISSUER,
"credentialSubject": { "id": "did:key:zHolder", "role": "member" },
})
}
fn held(id: &str, vc: Value) -> HeldCredential {
HeldCredential {
id: id.to_string(),
format: LDP_VC.to_string(),
claims: vc.clone(),
vct: None,
doctype: None,
supports_holder_binding: true,
vc,
}
}
fn membership_query() -> Value {
json!({
"credentials": [{
"id": "membership",
"format": "ldp_vc",
"claims": [{ "path": ["credentialSubject", "role"] }],
}],
})
}
#[test]
fn a_matching_credential_is_summarised_for_the_prompt() {
let held = vec![held("community:membership_credential", membership_vc())];
let request = evaluate_query(
&membership_query(),
&held,
VERIFIER,
"Verify your membership to join",
"nonce-1",
)
.expect("the held membership credential satisfies the query");
assert_eq!(request.verifier_did, VERIFIER);
assert_eq!(request.purpose, "Verify your membership to join");
assert_eq!(request.items.len(), 1);
assert_eq!(request.items[0].kind, "MembershipCredential");
assert_eq!(request.items[0].issuer.as_deref(), Some(ISSUER));
}
#[test]
fn an_unsatisfiable_query_does_not_match() {
let held = vec![held("community:membership_credential", membership_vc())];
let query = json!({
"credentials": [{
"id": "passport",
"format": "ldp_vc",
"claims": [{ "path": ["credentialSubject", "passportNumber"] }],
}],
});
assert!(matches!(
evaluate_query(&query, &held, VERIFIER, "why", "nonce-1"),
Err(PresentationError::NoMatch(_))
));
}
#[test]
fn an_empty_wallet_matches_nothing() {
assert!(matches!(
evaluate_query(&membership_query(), &[], VERIFIER, "why", "n"),
Err(PresentationError::NoMatch(_))
));
}
#[test]
fn disclosure_is_whole_credential() {
let held = vec![held("community:membership_credential", membership_vc())];
let request =
evaluate_query(&membership_query(), &held, VERIFIER, "why", "n").expect("matches");
assert!(request.discloses_whole_credentials());
}
#[test]
fn a_typeless_credential_falls_back_to_its_wallet_id() {
let vc = json!({
"type": ["VerifiableCredential"],
"credentialSubject": { "role": "member" },
});
let held = vec![held("vrc:abc123", vc)];
let request = evaluate_query(
&json!({
"credentials": [{
"id": "any",
"format": "ldp_vc",
"claims": [{ "path": ["credentialSubject", "role"] }],
}],
}),
&held,
VERIFIER,
"why",
"n",
)
.expect("matches");
assert_eq!(request.items[0].kind, "vrc:abc123");
assert_eq!(request.items[0].issuer, None);
}
#[test]
fn an_object_issuer_is_read_as_well_as_a_string_one() {
assert_eq!(
issuer_of(&json!({ "issuer": { "id": ISSUER, "name": "Acme" } })).as_deref(),
Some(ISSUER)
);
assert_eq!(
issuer_of(&json!({ "issuer": ISSUER })).as_deref(),
Some(ISSUER)
);
assert_eq!(issuer_of(&json!({})), None);
}
}