use super::jwt::{check_temporal, check_w3c_temporal, ed25519_from_okp_jwk};
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;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, VerifyingKey};
use serde_json::Value;
use vti_common::error::AppError;
use crate::credentials::vm_resolver::{DidVmResolver, check_issuer_binding};
struct EdDsaJwtVerifier {
key: VerifyingKey,
}
impl JwtVerifier for EdDsaJwtVerifier {
fn verify_jwt(&self, jws: &str) -> Result<Value, SdJwtError> {
let parts: Vec<&str> = jws.split('.').collect();
if parts.len() != 3 {
return Err(SdJwtError::Verification("malformed JWS".into()));
}
let signing_input = format!("{}.{}", parts[0], parts[1]);
let sig_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|e| SdJwtError::Verification(e.to_string()))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| SdJwtError::Verification(e.to_string()))?;
self.key
.verify_strict(signing_input.as_bytes(), &signature)
.map_err(|_| SdJwtError::Verification("signature did not verify".into()))?;
let payload = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| SdJwtError::Verification(e.to_string()))?;
serde_json::from_slice(&payload).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>,
}
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: String,
pub holder_key: VerifyingKey,
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 = header
.get("kid")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| issuer_did.clone());
if issuer_vm.split('#').next().unwrap_or_default() != issuer_did {
return Err(AppError::Validation(format!(
"SD-JWT issuer kid `{issuer_vm}` is not under `iss` (`{issuer_did}`)"
)));
}
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 = ed25519_from_okp_jwk(cnf_jwk)?;
let holder_did = affinidi_crypto::did_key::ed25519_pub_to_did_key(&holder_key.to_bytes());
Ok(ParsedSdJwtPresentation {
sd,
issuer_did,
issuer_vm,
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> {
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,
holder_key,
holder_did,
} = parse_sd_jwt_presentation(compact)?;
let hasher = Sha256Hasher;
let resolver = DidVmResolver::new(did_resolver.cloned());
let issuer_verifier = EdDsaJwtVerifier {
key: resolver.resolve_verifying_key(&issuer_vm).await?,
};
let holder_verifier = EdDsaJwtVerifier { 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);
Ok(VerifiedPresentation {
issuer_did,
holder_did,
vct,
holder_bound: true,
claims: result.claims,
credential_status,
})
}
#[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> {
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(entry, expected_aud, expected_nonce, did_resolver, 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, VerifyOptions};
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 proof: DataIntegrityProof = serde_json::from_value(proof_val.clone()).map_err(|e| {
AppError::Validation(format!("DI VP proof is not a Data-Integrity proof: {e}"))
})?;
if proof.proof_purpose != "authentication" {
return Err(AppError::Validation(format!(
"DI VP holder proof purpose is `{}`, expected `authentication`",
proof.proof_purpose
)));
}
let holder_did = proof
.verification_method
.split('#')
.next()
.unwrap_or_default()
.to_string();
let mut vp_unsigned = vp.clone();
if let Some(obj) = vp_unsigned.as_object_mut() {
obj.remove("proof");
}
proof
.verify(&vp_unsigned, &resolver, VerifyOptions::new())
.await
.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");
}
vc_proof
.verify(&vc_unsigned, &resolver, VerifyOptions::new())
.await
.map_err(|e| AppError::Validation(format!("DI VC issuer proof did not verify: {e}")))?;
check_w3c_temporal(vc, now)?;
let vct = vc.get("type").and_then(|t| match t {
Value::Array(a) => a
.iter()
.filter_map(Value::as_str)
.find(|s| *s != "VerifiableCredential")
.map(str::to_string),
Value::String(s) => Some(s.clone()),
_ => None,
});
let credential_status = extract_credential_status(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,
});
}
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)
.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 = vc.get("type").and_then(|t| match t {
Value::Array(a) => a
.iter()
.filter_map(Value::as_str)
.find(|s| *s != "VerifiableCredential")
.map(str::to_string),
Value::String(s) => Some(s.clone()),
_ => None,
});
let credential_status = extract_credential_status(vc);
Ok(VerifiedPresentation {
issuer_did,
holder_did,
vct,
holder_bound,
claims: vc.get("credentialSubject").cloned().unwrap_or(Value::Null),
credential_status,
})
}