use super::jwt::{aud_matches, decode_segment};
use affinidi_openid4vci::issuer::{
create_credential_offer, create_credential_response, validate_credential_request,
};
use affinidi_openid4vci::{CredentialOffer, CredentialRequest, CredentialResponse};
use chrono::{DateTime, Utc};
use serde_json::Value;
use vta_sdk::jws::JwsKey;
use vti_common::error::AppError;
pub(super) const OID4VCI_PROOF_TYP: &str = "openid4vci-proof+jwt";
pub(super) const PROOF_MAX_AGE_SECS: i64 = 300;
const PROOF_FUTURE_SKEW_SECS: i64 = 60;
#[derive(Debug, Clone)]
pub struct ProvenHolderProof {
pub holder_did: String,
pub nonce: Option<String>,
}
struct ProofParts<'a> {
jwt: &'a str,
kid: String,
holder_did: String,
}
fn proof_parts(proof_jwt: &str) -> Result<ProofParts<'_>, AppError> {
let mut parts = proof_jwt.split('.');
let h_b64 = match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(h), Some(_), Some(_), None) => h,
_ => {
return Err(AppError::Validation(
"key-binding proof is not a compact JWS (header.payload.signature)".into(),
));
}
};
let header = decode_segment(h_b64, "proof header")?;
if header.get("typ").and_then(Value::as_str) != Some(OID4VCI_PROOF_TYP) {
return Err(AppError::Validation(format!(
"key-binding proof `typ` must be `{OID4VCI_PROOF_TYP}`"
)));
}
let kid = header
.get("kid")
.and_then(Value::as_str)
.ok_or_else(|| AppError::Validation("key-binding proof header has no `kid`".into()))?
.to_string();
let holder_did = kid.split('#').next().unwrap_or(&kid).to_string();
Ok(ProofParts {
jwt: proof_jwt,
kid,
holder_did,
})
}
fn verify_with_key(
parts: ProofParts<'_>,
key: &JwsKey,
expected_aud: &str,
now: DateTime<Utc>,
) -> Result<ProvenHolderProof, AppError> {
let payload = key.verify_compact(parts.jwt).map_err(|e| match e {
vta_sdk::jws::JwsError::BadSignature => {
AppError::Validation("key-binding proof signature did not verify".into())
}
other => AppError::Validation(format!("key-binding proof refused: {other}")),
})?;
if !aud_matches(payload.get("aud"), expected_aud) {
return Err(AppError::Validation(format!(
"key-binding proof `aud` does not name this issuer ({expected_aud})"
)));
}
let iat = payload
.get("iat")
.and_then(Value::as_i64)
.ok_or_else(|| AppError::Validation("key-binding proof has no numeric `iat`".into()))?;
let now_secs = now.timestamp();
if iat > now_secs + PROOF_FUTURE_SKEW_SECS {
return Err(AppError::Validation(
"key-binding proof `iat` is in the future".into(),
));
}
if now_secs - iat > PROOF_MAX_AGE_SECS {
return Err(AppError::Validation(format!(
"key-binding proof is stale (older than {PROOF_MAX_AGE_SECS}s)"
)));
}
let nonce = payload
.get("nonce")
.and_then(Value::as_str)
.map(str::to_string);
Ok(ProvenHolderProof {
holder_did: parts.holder_did,
nonce,
})
}
pub fn verify_oid4vci_proof(
proof_jwt: &str,
expected_aud: &str,
now: DateTime<Utc>,
) -> Result<ProvenHolderProof, AppError> {
let parts = proof_parts(proof_jwt)?;
if !parts.holder_did.starts_with("did:key:") {
return Err(AppError::Validation(format!(
"key-binding proof `kid` ({}) is not a `did:key` — use the resolving verifier",
parts.holder_did
)));
}
let key = JwsKey::from_did_key(&parts.holder_did).map_err(|e| {
AppError::Validation(format!(
"holder `{}` is not a usable did:key: {e}",
parts.holder_did
))
})?;
verify_with_key(parts, &key, expected_aud, now)
}
pub async fn verify_oid4vci_proof_resolved(
proof_jwt: &str,
expected_aud: &str,
now: DateTime<Utc>,
resolver: &crate::credentials::vm_resolver::DidVmResolver,
) -> Result<ProvenHolderProof, AppError> {
let parts = proof_parts(proof_jwt)?;
let key = resolver
.resolve_jws_key(&parts.kid, vti_common::auth::ProofPurpose::Authentication)
.await
.map_err(|e| {
AppError::Validation(format!(
"key-binding proof `kid` did not resolve to an authentication key: {e}"
))
})?;
verify_with_key(parts, &key, expected_aud, now)
}
pub async fn issue_on_request(
request: &CredentialRequest,
credential: Value,
expected_holder_did: &str,
issuer_id: &str,
now: DateTime<Utc>,
resolver: &crate::credentials::vm_resolver::DidVmResolver,
) -> Result<CredentialResponse, AppError> {
validate_credential_request(request)
.map_err(|e| AppError::Validation(format!("invalid credential request: {e}")))?;
let proof = request.proof.as_ref().ok_or_else(|| {
AppError::Validation(
"credential request carries no key-binding proof — issuance requires \
proof of holder key possession"
.into(),
)
})?;
if proof.proof_type != "jwt" {
return Err(AppError::Validation(format!(
"unsupported key-binding proof type `{}` (expected `jwt`)",
proof.proof_type
)));
}
let proven = verify_oid4vci_proof_resolved(&proof.jwt, issuer_id, now, resolver).await?;
if proven.holder_did != expected_holder_did {
return Err(AppError::Forbidden(format!(
"key-binding proof proves control of {} but the credential is bound to {}",
proven.holder_did, expected_holder_did
)));
}
Ok(create_credential_response(credential, None, None))
}
pub fn credential_offer(
issuer_id: &str,
config_ids: Vec<String>,
pre_authorized_code: String,
) -> CredentialOffer {
create_credential_offer(issuer_id, config_ids, Some(pre_authorized_code))
}