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 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;
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>,
}
pub fn verify_oid4vci_proof(
proof_jwt: &str,
expected_aud: &str,
now: DateTime<Utc>,
) -> Result<ProvenHolderProof, AppError> {
let mut parts = proof_jwt.split('.');
let (h_b64, p_b64, s_b64) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s), None) => (h, p, s),
_ => {
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}`"
)));
}
if header.get("alg").and_then(Value::as_str) != Some("EdDSA") {
return Err(AppError::Validation(
"key-binding proof `alg` must be `EdDSA` (Ed25519)".into(),
));
}
let kid = header
.get("kid")
.and_then(Value::as_str)
.ok_or_else(|| AppError::Validation("key-binding proof header has no `kid`".into()))?;
let holder_did = kid.split('#').next().unwrap_or(kid).to_string();
if !holder_did.starts_with("did:key:") {
return Err(AppError::Validation(format!(
"key-binding proof `kid` ({holder_did}) is not a `did:key` — resolving a \
did:webvh / did:web holder needs the DID resolver, a follow-up slice"
)));
}
let pub_bytes = affinidi_crypto::did_key::did_key_to_ed25519_pub(&holder_did).map_err(|e| {
AppError::Validation(format!("holder `{holder_did}` is not a did:key: {e}"))
})?;
let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
.map_err(|e| AppError::Validation(format!("holder key is not a valid Ed25519 key: {e}")))?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(s_b64)
.map_err(|e| AppError::Validation(format!("proof signature is not base64url: {e}")))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| AppError::Validation(format!("proof signature is malformed: {e}")))?;
let signing_input = format!("{h_b64}.{p_b64}");
verifying_key
.verify_strict(signing_input.as_bytes(), &signature)
.map_err(|_| AppError::Validation("key-binding proof signature did not verify".into()))?;
let payload = decode_segment(p_b64, "proof payload")?;
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, nonce })
}
pub fn issue_on_request(
request: &CredentialRequest,
credential: Value,
expected_holder_did: &str,
issuer_id: &str,
now: DateTime<Utc>,
) -> 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(&proof.jwt, issuer_id, now)?;
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))
}