use std::collections::HashMap;
use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use affinidi_openid4vp::{CandidateCredential, ClaimPathSegment, DcqlQuery, Oid4vpError};
use affinidi_secrets_resolver::secrets::Secret;
use serde_json::{Value, json};
const VC_V2_CONTEXT_URL: &str = "https://www.w3.org/ns/credentials/v2";
#[derive(Debug, thiserror::Error)]
pub enum VpError {
#[error("DCQL: {0}")]
Dcql(#[from] Oid4vpError),
#[error("DCQL match referenced unknown candidate id `{0}`")]
UnknownCandidate(String),
#[error("DCQL match produced no credentials to present")]
EmptySelection,
#[error("sign vp_token presentation: {0}")]
Sign(String),
#[error("serialize vp_token: {0}")]
Serialize(String),
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HeldCredential {
pub id: String,
pub format: String,
pub claims: Value,
#[serde(default)]
pub vct: Option<String>,
#[serde(default)]
pub doctype: Option<String>,
#[serde(default = "default_true")]
pub supports_holder_binding: bool,
pub vc: Value,
}
fn default_true() -> bool {
true
}
impl HeldCredential {
fn to_candidate(&self) -> CandidateCredential {
CandidateCredential {
id: self.id.clone(),
format: self.format.clone(),
claims: self.claims.clone(),
vct: self.vct.clone(),
doctype: self.doctype.clone(),
supports_holder_binding: self.supports_holder_binding,
}
}
}
#[derive(Debug, Clone)]
pub struct SelectedCredential {
pub credential_query_id: String,
pub credential: HeldCredential,
pub disclosed_paths: Vec<Vec<ClaimPathSegment>>,
}
#[derive(Debug, Clone)]
pub struct CandidateSet {
pub entries: Vec<SelectedCredential>,
}
impl CandidateSet {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub fn select_credentials(
presentation_definition: &Value,
held: &[HeldCredential],
) -> Result<CandidateSet, VpError> {
let query = DcqlQuery::from_json(presentation_definition)?;
let candidates: Vec<CandidateCredential> =
held.iter().map(HeldCredential::to_candidate).collect();
let dcql_match = query.match_credentials(&candidates)?;
let by_id: HashMap<&str, &HeldCredential> = held.iter().map(|h| (h.id.as_str(), h)).collect();
let mut entries = Vec::with_capacity(dcql_match.matches.len());
for m in dcql_match.matches {
let cred = by_id
.get(m.candidate_id.as_str())
.ok_or_else(|| VpError::UnknownCandidate(m.candidate_id.clone()))?;
entries.push(SelectedCredential {
credential_query_id: m.credential_query_id,
credential: (*cred).clone(),
disclosed_paths: m.disclosed_paths,
});
}
Ok(CandidateSet { entries })
}
pub async fn build_vp_token(
candidates: &CandidateSet,
holder_signer: &Secret,
nonce: &str,
audience: &str,
) -> Result<Value, VpError> {
if candidates.is_empty() {
return Err(VpError::EmptySelection);
}
let holder_did = holder_signer
.id
.split_once('#')
.map(|(did, _)| did)
.unwrap_or(holder_signer.id.as_str());
let mut order: Vec<String> = Vec::new();
let mut grouped: HashMap<String, Vec<Value>> = HashMap::new();
for entry in &candidates.entries {
if !grouped.contains_key(&entry.credential_query_id) {
order.push(entry.credential_query_id.clone());
}
grouped
.entry(entry.credential_query_id.clone())
.or_default()
.push(entry.credential.vc.clone());
}
let mut vp_token = serde_json::Map::new();
for query_id in order {
let vcs = grouped.remove(&query_id).unwrap_or_default();
let vp = build_di_vp(holder_did, vcs, nonce, audience, holder_signer).await?;
vp_token.insert(query_id, vp);
}
Ok(Value::Object(vp_token))
}
pub async fn issue_vp_token(
holder_did: &str,
holder_vm_fragment: &str,
holder_key_multibase: &str,
presentation_definition: &Value,
held: &[HeldCredential],
nonce: &str,
audience: &str,
) -> Result<Value, VpError> {
let kid = format!("{holder_did}#{holder_vm_fragment}");
let secret = Secret::from_multibase(holder_key_multibase, Some(&kid))
.map_err(|e| VpError::Sign(format!("holder key: {e}")))?;
let candidates = select_credentials(presentation_definition, held)?;
build_vp_token(&candidates, &secret, nonce, audience).await
}
async fn build_di_vp(
holder_did: &str,
vcs: Vec<Value>,
nonce: &str,
audience: &str,
holder_signer: &Secret,
) -> Result<Value, VpError> {
let vp = json!({
"@context": [VC_V2_CONTEXT_URL],
"type": ["VerifiablePresentation"],
"holder": holder_did,
"verifiableCredential": vcs,
"nonce": nonce,
"domain": audience,
});
let proof = DataIntegrityProof::sign(
&vp,
holder_signer,
SignOptions::new()
.with_proof_purpose("authentication")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| VpError::Sign(e.to_string()))?;
let mut signed = vp;
signed.as_object_mut().expect("vp is an object").insert(
"proof".into(),
serde_json::to_value(proof).map_err(|e| VpError::Serialize(e.to_string()))?,
);
Ok(signed)
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_data_integrity::{DidKeyResolver, VerifyOptions};
fn test_secret(seed_byte: u8) -> Secret {
let seed = [seed_byte; 32];
let mut secret = Secret::generate_ed25519(None, Some(&seed));
let pub_mb = secret.get_public_keymultibase().unwrap();
secret.id = format!("did:key:{pub_mb}#{pub_mb}");
secret
}
fn did_of(secret: &Secret) -> String {
secret.id.split_once('#').unwrap().0.to_string()
}
async fn signed_vc(issuer: &Secret, subject_did: &str, mut subject: Value) -> Value {
subject
.as_object_mut()
.unwrap()
.insert("id".into(), json!(subject_did));
let vc = json!({
"@context": [VC_V2_CONTEXT_URL],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": did_of(issuer),
"credentialSubject": subject,
});
let proof = DataIntegrityProof::sign(
&vc,
issuer,
SignOptions::new().with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.unwrap();
let mut signed = vc;
signed
.as_object_mut()
.unwrap()
.insert("proof".into(), serde_json::to_value(proof).unwrap());
signed
}
fn membership_pd() -> Value {
json!({
"credentials": [{
"id": "membership",
"format": "ldp_vc",
"claims": [ { "path": ["givenName"] } ]
}]
})
}
fn held(id: &str, subject: Value, vc: Value) -> HeldCredential {
HeldCredential {
id: id.to_string(),
format: "ldp_vc".to_string(),
claims: subject,
vct: None,
doctype: None,
supports_holder_binding: true,
vc,
}
}
#[test]
fn select_credentials_matches_the_membership_query() {
let subject = json!({ "givenName": "Ada", "memberSince": "2024-01-01" });
let set = select_credentials(
&membership_pd(),
&[held(
"vmc",
subject.clone(),
json!({"credentialSubject": subject}),
)],
)
.expect("a matching credential");
assert_eq!(set.entries.len(), 1);
let entry = &set.entries[0];
assert_eq!(entry.credential_query_id, "membership");
assert_eq!(entry.credential.id, "vmc");
assert_eq!(
entry.disclosed_paths,
vec![vec![ClaimPathSegment::Name("givenName".into())]]
);
}
#[test]
fn select_credentials_errors_when_no_credential_satisfies_the_query() {
let subject = json!({ "familyName": "Lovelace" });
let err = select_credentials(
&membership_pd(),
&[held(
"vmc",
subject.clone(),
json!({"credentialSubject": subject}),
)],
)
.expect_err("no match");
assert!(matches!(err, VpError::Dcql(_)), "got {err:?}");
}
#[tokio::test]
async fn build_vp_token_produces_a_holder_bound_di_vp_that_verifies() {
let holder = test_secret(7);
let issuer = test_secret(9);
let holder_did = did_of(&holder);
let subject = json!({ "givenName": "Ada", "memberSince": "2024-01-01" });
let vc = signed_vc(&issuer, &holder_did, subject.clone()).await;
let set = select_credentials(
&membership_pd(),
&[held("vmc", vc["credentialSubject"].clone(), vc.clone())],
)
.unwrap();
let token = build_vp_token(&set, &holder, "nonce-123", "did:web:community.example")
.await
.expect("build vp_token");
let vp = token
.as_object()
.and_then(|m| m.get("membership"))
.expect("vp_token keyed by `membership`");
assert_eq!(vp["holder"], json!(holder_did));
assert_eq!(vp["nonce"], json!("nonce-123"));
assert_eq!(vp["domain"], json!("did:web:community.example"));
assert_eq!(
vp["verifiableCredential"][0]["credentialSubject"]["givenName"],
json!("Ada")
);
let di: DataIntegrityProof = serde_json::from_value(vp["proof"].clone()).unwrap();
assert_eq!(di.proof_purpose, "authentication");
assert_eq!(
di.verification_method.split('#').next().unwrap(),
holder_did
);
let mut unsigned = vp.clone();
unsigned.as_object_mut().unwrap().remove("proof");
di.verify(&unsigned, &DidKeyResolver, VerifyOptions::new())
.await
.expect("holder proof verifies");
}
#[tokio::test]
async fn build_vp_token_rejects_an_empty_selection() {
let holder = test_secret(7);
let err = build_vp_token(&CandidateSet { entries: vec![] }, &holder, "n", "aud")
.await
.expect_err("empty selection");
assert!(matches!(err, VpError::EmptySelection));
}
}