use super::jwt::{check_temporal, check_w3c_temporal};
use affinidi_sd_jwt::SdJwt;
use affinidi_sd_jwt::error::SdJwtError;
use affinidi_sd_jwt::hasher::Sha256Hasher;
use affinidi_sd_jwt::signer::JwtVerifier;
use affinidi_sd_jwt::verifier::{VerificationOptions, verify as verify_sd_jwt};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use serde_json::Value;
use vta_sdk::jws::{JwsKey, sd_jwt_issuer_method};
use vta_sdk::x509_issuer::X509TrustAnchors;
use vti_common::auth::PurposeVmResolver;
use vti_common::error::AppError;
use crate::credentials::vm_resolver::{DidVmResolver, check_issuer_binding};
struct KeyJwtVerifier {
key: JwsKey,
}
impl JwtVerifier for KeyJwtVerifier {
fn verify_jwt(&self, jws: &str) -> Result<Value, SdJwtError> {
self.key
.verify_compact(jws)
.map_err(|e| SdJwtError::Verification(e.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct VerifiedPresentation {
pub issuer_did: String,
pub holder_did: String,
pub vct: Option<String>,
pub claims: Value,
pub holder_bound: bool,
pub credential_status: Option<Value>,
pub task_context: Option<String>,
pub issuer_authority_key_ids: Vec<String>,
}
fn extract_task_context(source: &Value) -> Option<String> {
source
.get("taskContext")
.and_then(Value::as_str)
.map(str::to_string)
}
fn extract_credential_status(source: &Value) -> Option<Value> {
source
.get("credentialStatus")
.or_else(|| source.get("status"))
.cloned()
}
#[derive(Debug)]
pub struct ParsedSdJwtPresentation {
pub sd: SdJwt,
pub issuer_did: String,
pub issuer_vm: Option<String>,
pub issuer_header: Value,
pub holder_key: JwsKey,
pub holder_did: String,
}
pub fn parse_sd_jwt_presentation(compact: &str) -> Result<ParsedSdJwtPresentation, AppError> {
let hasher = Sha256Hasher;
let sd = SdJwt::parse(compact, &hasher)
.map_err(|e| AppError::Validation(format!("vp_token is not a parseable SD-JWT-VC: {e}")))?;
if sd.kb_jwt.is_none() {
return Err(AppError::Validation(
"presentation carries no holder kb-jwt (unbound presentation refused)".into(),
));
}
let payload = sd
.payload()
.map_err(|e| AppError::Validation(format!("presentation payload: {e}")))?;
let issuer_did = payload
.get("iss")
.and_then(Value::as_str)
.ok_or_else(|| AppError::Validation("presentation has no `iss`".into()))?
.to_string();
let header = sd
.header()
.map_err(|e| AppError::Validation(format!("presentation header: {e}")))?;
let issuer_vm =
if !issuer_did.starts_with("did:") && vta_sdk::x509_issuer::names_key_by_x5c(&header) {
None
} else {
Some(
sd_jwt_issuer_method(&header, &issuer_did)
.map_err(|e| AppError::Validation(format!("SD-JWT issuer: {e}")))?,
)
};
let cnf_jwk = payload
.get("cnf")
.and_then(|c| c.get("jwk"))
.ok_or_else(|| {
AppError::Validation("presentation has no `cnf.jwk` (holder binding)".into())
})?;
let holder_key =
JwsKey::from_jwk(cnf_jwk).map_err(|e| AppError::Validation(format!("cnf.jwk: {e}")))?;
let holder_did = holder_key.did_key();
Ok(ParsedSdJwtPresentation {
sd,
issuer_did,
issuer_vm,
issuer_header: header,
holder_key,
holder_did,
})
}
pub async fn verify_presentation(
vp_token: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
now: DateTime<Utc>,
) -> Result<VerifiedPresentation, AppError> {
verify_presentation_with_anchors(
vp_token,
expected_aud,
expected_nonce,
did_resolver,
&X509TrustAnchors::new(),
now,
)
.await
}
pub async fn verify_presentation_with_anchors(
vp_token: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
anchors: &X509TrustAnchors,
now: DateTime<Utc>,
) -> Result<VerifiedPresentation, AppError> {
let compact = vp_token.as_str().ok_or_else(|| {
AppError::Validation("vp_token must be a compact SD-JWT-VC string".into())
})?;
let ParsedSdJwtPresentation {
sd,
issuer_did,
issuer_vm,
issuer_header,
holder_key,
holder_did,
} = parse_sd_jwt_presentation(compact)?;
let hasher = Sha256Hasher;
let mut issuer_authority_key_ids = Vec::new();
let key = match issuer_vm {
Some(issuer_vm) => {
let resolver = DidVmResolver::new(did_resolver.cloned());
let issuer_key = resolver
.resolve_vm_for_purpose(&issuer_vm, vti_common::auth::ProofPurpose::AssertionMethod)
.await
.map_err(|e| AppError::Validation(format!("verification method refused: {e}")))?;
JwsKey::from_resolved(&issuer_key)
.map_err(|e| AppError::Validation(format!("issuer key: {e}")))?
}
None => {
let issuer = vta_sdk::x509_issuer::x5c_issuer(
&issuer_header,
&issuer_did,
anchors,
now.timestamp(),
)
.map_err(|e| AppError::Validation(format!("issuer certificate: {e}")))?;
issuer_authority_key_ids = issuer
.authority_key_ids
.iter()
.map(|id| URL_SAFE_NO_PAD.encode(id))
.collect();
issuer.key
}
};
let issuer_verifier = KeyJwtVerifier { key };
let holder_verifier = KeyJwtVerifier { key: holder_key };
let options = VerificationOptions {
verify_kb: true,
expected_audience: Some(expected_aud),
expected_nonce: Some(expected_nonce),
};
let result = verify_sd_jwt(
&sd,
&issuer_verifier,
&hasher,
&options,
Some(&holder_verifier),
)
.map_err(|e| AppError::Validation(format!("presentation verification failed: {e}")))?;
if !result.is_verified() {
return Err(AppError::Validation(
"holder key-binding (kb-jwt) did not verify".into(),
));
}
check_temporal(&result.claims, now)?;
let vct = result
.claims
.get("vct")
.and_then(Value::as_str)
.map(str::to_string);
let credential_status = extract_credential_status(&result.claims);
let task_context = extract_task_context(&result.claims);
Ok(VerifiedPresentation {
issuer_did,
holder_did,
vct,
holder_bound: true,
claims: result.claims,
credential_status,
task_context,
issuer_authority_key_ids,
})
}
#[derive(Debug, Clone)]
pub struct VerifiedPresentationSet {
pub holder: String,
pub presentations: Vec<VerifiedPresentation>,
}
pub fn flatten_vp_token(vp_token: &Value) -> Result<Vec<&Value>, AppError> {
match vp_token {
Value::String(_) => Ok(vec![vp_token]),
Value::Object(map) => {
if map.is_empty() {
return Err(AppError::Validation(
"vp_token is an empty object (no presentations)".into(),
));
}
let mut out = Vec::new();
for value in map.values() {
match value {
Value::Array(items) => out.extend(items.iter()),
other => out.push(other),
}
}
Ok(out)
}
_ => Err(AppError::Validation(
"vp_token must be a DCQL object or a compact SD-JWT-VC string".into(),
)),
}
}
pub async fn verify_vp_token(
vp_token: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
now: DateTime<Utc>,
) -> Result<VerifiedPresentationSet, AppError> {
verify_vp_token_with_anchors(
vp_token,
expected_aud,
expected_nonce,
did_resolver,
&X509TrustAnchors::new(),
now,
)
.await
}
pub async fn verify_vp_token_with_anchors(
vp_token: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
anchors: &X509TrustAnchors,
now: DateTime<Utc>,
) -> Result<VerifiedPresentationSet, AppError> {
let entries = flatten_vp_token(vp_token)?;
let mut presentations = Vec::with_capacity(entries.len());
let mut holder: Option<String> = None;
for entry in entries {
let verified: Vec<VerifiedPresentation> = if entry.is_object() {
if is_bbs_2023_presentation(entry) {
verify_bbs_dispatch(entry, expected_aud, expected_nonce, did_resolver, now).await?
} else {
verify_di_vp(entry, expected_aud, expected_nonce, did_resolver, now).await?
}
} else {
vec![
verify_presentation_with_anchors(
entry,
expected_aud,
expected_nonce,
did_resolver,
anchors,
now,
)
.await?,
]
};
for v in verified {
match &holder {
None => holder = Some(v.holder_did.clone()),
Some(h) if h != &v.holder_did => {
return Err(AppError::Validation(format!(
"vp_token presentations disagree on the holder (`{h}` vs \
`{}`) — a single presentation must bind one holder",
v.holder_did
)));
}
Some(_) => {}
}
presentations.push(v);
}
}
let holder = holder.ok_or_else(|| {
AppError::Validation("vp_token carried no presentations to verify".into())
})?;
Ok(VerifiedPresentationSet {
holder,
presentations,
})
}
async fn verify_di_vp(
vp: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
now: DateTime<Utc>,
) -> Result<Vec<VerifiedPresentation>, AppError> {
use affinidi_data_integrity::DataIntegrityProof;
let resolver = DidVmResolver::new(did_resolver.cloned());
let proof_val = vp
.get("proof")
.ok_or_else(|| AppError::Validation("DI VP has no `proof` (holder binding)".into()))?;
let proofs = crate::credentials::proof_set::proof_set(proof_val).map_err(|e| {
AppError::Validation(format!("DI VP proof is not a Data-Integrity proof: {e}"))
})?;
for proof in &proofs {
if proof.proof_purpose != "authentication" {
return Err(AppError::Validation(format!(
"DI VP holder proof purpose is `{}`, expected `authentication`",
proof.proof_purpose
)));
}
}
let mut vp_unsigned = vp.clone();
if let Some(obj) = vp_unsigned.as_object_mut() {
obj.remove("proof");
}
let mut outcomes: Vec<(String, Result<(), String>)> = Vec::with_capacity(proofs.len());
for proof in &proofs {
let did = crate::credentials::proof_set::proof_signer_did(proof).to_string();
let r = crate::credentials::proof_set::verify_one(
proof,
&vp_unsigned,
&resolver,
vti_common::auth::ProofPurpose::Authentication,
)
.await;
outcomes.push((did, r));
}
let holder_did = crate::credentials::proof_set::accept_all(&outcomes)
.map_err(|e| AppError::Validation(format!("DI VP holder proof did not verify: {e}")))?;
if vp.get("nonce").and_then(Value::as_str) != Some(expected_nonce) {
return Err(AppError::Validation(
"DI VP `nonce` does not match the verifier's challenge".into(),
));
}
if vp.get("domain").and_then(Value::as_str) != Some(expected_aud) {
return Err(AppError::Validation(
"DI VP `domain` does not name this verifier".into(),
));
}
let vcs = vp
.get("verifiableCredential")
.and_then(Value::as_array)
.filter(|a| !a.is_empty())
.ok_or_else(|| {
AppError::Validation("DI VP has no `verifiableCredential` to verify".into())
})?;
let mut out = Vec::with_capacity(vcs.len());
for vc in vcs {
let issuer_did = vc
.get("issuer")
.and_then(|i| match i {
Value::String(s) => Some(s.clone()),
Value::Object(o) => o.get("id").and_then(Value::as_str).map(str::to_string),
_ => None,
})
.ok_or_else(|| AppError::Validation("DI VC has no `issuer`".into()))?;
let vc_proof_val = vc
.get("proof")
.ok_or_else(|| AppError::Validation("DI VC has no issuer `proof`".into()))?;
let vc_proof: DataIntegrityProof =
serde_json::from_value(vc_proof_val.clone()).map_err(|e| {
AppError::Validation(format!("DI VC proof is not a Data-Integrity proof: {e}"))
})?;
check_issuer_binding(&vc_proof.verification_method, &issuer_did)?;
let mut vc_unsigned = vc.clone();
if let Some(obj) = vc_unsigned.as_object_mut() {
obj.remove("proof");
}
crate::credentials::proof_set::verify_one(
&vc_proof,
&vc_unsigned,
&resolver,
vti_common::auth::ProofPurpose::AssertionMethod,
)
.await
.map_err(|e| AppError::Validation(format!("DI VC issuer proof did not verify: {e}")))?;
check_w3c_temporal(vc, now)?;
let vct = crate::credentials::ingress::concrete_type(vc);
let credential_status = extract_credential_status(vc);
let task_context = extract_task_context(vc);
out.push(VerifiedPresentation {
issuer_did,
holder_did: holder_did.clone(),
vct,
holder_bound: true,
claims: vc.get("credentialSubject").cloned().unwrap_or(Value::Null),
credential_status,
task_context,
issuer_authority_key_ids: Vec::new(),
});
}
Ok(out)
}
fn is_bbs_2023_presentation(entry: &Value) -> bool {
entry
.get("proof")
.and_then(|p| p.get("cryptosuite"))
.and_then(Value::as_str)
== Some("bbs-2023")
}
async fn verify_bbs_dispatch(
entry: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
now: DateTime<Utc>,
) -> Result<Vec<VerifiedPresentation>, AppError> {
#[cfg(feature = "bbs")]
{
Ok(vec![
verify_bbs_presentation(entry, expected_aud, expected_nonce, did_resolver, now).await?,
])
}
#[cfg(not(feature = "bbs"))]
{
let _ = (entry, expected_aud, expected_nonce, did_resolver, now);
Err(AppError::Validation(
"a bbs-2023 presentation was received but this VTC was built without the `bbs` \
feature"
.into(),
))
}
}
#[cfg(feature = "bbs")]
fn bbs_inspect_derived_proof(vc: &Value) -> Result<(Vec<u8>, bool), AppError> {
let pv = vc
.get("proof")
.and_then(|p| p.get("proofValue"))
.and_then(Value::as_str)
.ok_or_else(|| AppError::Validation("bbs-2023 presentation has no `proofValue`".into()))?;
let (_base, bytes) = multibase::decode(pv).map_err(|e| {
AppError::Validation(format!("bbs-2023 `proofValue` is not multibase: {e}"))
})?;
if bytes.len() < 3 || bytes[0] != 0xd9 || bytes[1] != 0x5d {
return Err(AppError::Validation(
"bbs-2023 `proofValue` is not a derived (disclosure) proof".into(),
));
}
let is_pseudonym = match bytes[2] {
0x03 => false,
0x09 => true,
_ => {
return Err(AppError::Validation(
"bbs-2023 `proofValue` is not a derived (disclosure) proof".into(),
));
}
};
let value: ciborium::value::Value = ciborium::from_reader(&bytes[3..]).map_err(|e| {
AppError::Validation(format!("bbs-2023 `proofValue` CBOR is malformed: {e}"))
})?;
let header = value
.as_array()
.and_then(|arr| arr.get(4))
.and_then(ciborium::value::Value::as_bytes)
.ok_or_else(|| {
AppError::Validation("bbs-2023 derived `proofValue` has no presentationHeader".into())
})?;
Ok((header.clone(), is_pseudonym))
}
#[cfg(feature = "bbs")]
async fn verify_bbs_presentation(
vc: &Value,
expected_aud: &str,
expected_nonce: &str,
did_resolver: Option<&affinidi_did_resolver_cache_sdk::DIDCacheClient>,
now: DateTime<Utc>,
) -> Result<VerifiedPresentation, AppError> {
use affinidi_bbs::PublicKey;
use affinidi_data_integrity::bbs_2023_transform;
let issuer_did = vc
.get("issuer")
.and_then(|i| match i {
Value::String(s) => Some(s.clone()),
Value::Object(o) => o.get("id").and_then(Value::as_str).map(str::to_string),
_ => None,
})
.ok_or_else(|| AppError::Validation("bbs-2023 VC has no `issuer`".into()))?;
let vm = vc
.get("proof")
.and_then(|p| p.get("verificationMethod"))
.and_then(Value::as_str)
.ok_or_else(|| AppError::Validation("bbs-2023 proof has no `verificationMethod`".into()))?;
check_issuer_binding(vm, &issuer_did)?;
let g2 = DidVmResolver::new(did_resolver.cloned())
.resolve_bbs_g2(vm, vti_common::auth::ProofPurpose::AssertionMethod)
.await?;
let pk = PublicKey::from_bytes(&g2)
.map_err(|e| AppError::Validation(format!("bbs-2023 issuer key is invalid: {e}")))?;
let (header, holder_bound) = bbs_inspect_derived_proof(vc)?;
if header != expected_nonce.as_bytes() {
return Err(AppError::Validation(
"bbs-2023 presentation header does not match the expected challenge".into(),
));
}
let verified = if holder_bound {
bbs_2023_transform::verify_pseudonym_derived_proof(vc, &pk, expected_aud)
} else {
bbs_2023_transform::verify_derived_proof(vc, &pk)
}
.map_err(|e| AppError::Validation(format!("bbs-2023 presentation did not verify: {e}")))?;
if !verified {
return Err(AppError::Validation(
"bbs-2023 presentation proof did not verify".into(),
));
}
check_w3c_temporal(vc, now)?;
let holder_did = vc
.get("credentialSubject")
.and_then(|s| s.get("id"))
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| {
AppError::Validation(
"bbs-2023 presentation discloses no `credentialSubject.id` (the applicant); \
the subject id must be a mandatory-disclosed claim"
.into(),
)
})?;
let vct = crate::credentials::ingress::concrete_type(vc);
let credential_status = extract_credential_status(vc);
let task_context = extract_task_context(vc);
Ok(VerifiedPresentation {
issuer_did,
holder_did,
vct,
holder_bound,
claims: vc.get("credentialSubject").cloned().unwrap_or(Value::Null),
credential_status,
task_context,
issuer_authority_key_ids: Vec::new(),
})
}