use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use chrono::{DateTime, Utc};
use serde_json::Value;
use tracing::debug;
use crate::proof_check::{self, ProofError, Purpose};
pub const VALID_FROM_SKEW: chrono::TimeDelta = chrono::TimeDelta::minutes(5);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum IssuedCredentialError {
#[error("the credential was not checked")]
NotChecked,
#[error("the delivery is not a Trust Task document naming the community that sent it")]
DeliveryIssuerNotSender,
#[error("the delivery's proof: {0}")]
DeliveryProof(ProofError),
#[error("the delivery carries no credential")]
NoCredential,
#[error("its check did not finish (timed out or failed) — ask the community to send it again")]
CheckUnfinished,
#[error("the credential names no issuer")]
NoIssuer,
#[error("the credential's issuer is not the community that sent it")]
IssuerNotSender,
#[error("the credential's proof: {0}")]
Proof(#[from] ProofError),
#[error("the credential's {0} is not a valid timestamp")]
BadTimestamp(&'static str),
#[error("the credential is not valid yet")]
NotYetValid,
#[error("the credential has expired")]
Expired,
#[error("the community has revoked or suspended the credential")]
Revoked,
#[error(
"the credential's revocation status could not be checked (the community's status \
list was unreachable or did not verify); try again once the community is \
reachable, or ask it to re-issue the credential"
)]
StatusUnknown,
}
#[derive(Debug, Clone)]
pub struct VerifiedIssuedCredential(Value);
impl VerifiedIssuedCredential {
#[must_use]
pub fn value(&self) -> &Value {
&self.0
}
#[must_use]
pub fn into_value(self) -> Value {
self.0
}
#[cfg(test)]
pub(crate) fn assume_verified(value: Value) -> Self {
Self(value)
}
}
#[must_use]
pub fn issuer_of(credential: &Value) -> Option<&str> {
match credential.get("issuer")? {
Value::String(s) => Some(s.as_str()),
Value::Object(o) => o.get("id").and_then(Value::as_str),
_ => None,
}
}
pub async fn verify_issued_delivery(
document: &Value,
sender: &str,
resolver: &DIDCacheClient,
now: DateTime<Utc>,
) -> Result<VerifiedIssuedCredential, IssuedCredentialError> {
if document.get("issuer").and_then(Value::as_str) != Some(sender) {
return Err(IssuedCredentialError::DeliveryIssuerNotSender);
}
proof_check::verify_signed(document, sender, resolver, &[Purpose::Authentication])
.await
.map_err(IssuedCredentialError::DeliveryProof)?;
let credential = document
.pointer("/payload/credential_response/credential")
.cloned()
.ok_or(IssuedCredentialError::NoCredential)?;
verify_issued_credential(credential, sender, resolver, now).await
}
pub async fn verify_issued_credential(
credential: Value,
sender: &str,
resolver: &DIDCacheClient,
now: DateTime<Utc>,
) -> Result<VerifiedIssuedCredential, IssuedCredentialError> {
let issuer = issuer_of(&credential).ok_or(IssuedCredentialError::NoIssuer)?;
if issuer != sender {
return Err(IssuedCredentialError::IssuerNotSender);
}
check_validity_window(&credential, now)?;
proof_check::verify_signed(&credential, issuer, resolver, &[Purpose::AssertionMethod]).await?;
if let Some(status) = credential.get("credentialStatus") {
check_status(status, issuer, resolver, now).await?;
}
Ok(VerifiedIssuedCredential(credential))
}
async fn check_status(
status: &Value,
issuer: &str,
resolver: &DIDCacheClient,
now: DateTime<Utc>,
) -> Result<(), IssuedCredentialError> {
use crate::status_list::{StatusCheck, check_credential_status};
use crate::vetting::status::{STATUS_FETCH_TIMEOUT, fetch_owned, status_client};
let client = status_client(true, STATUS_FETCH_TIMEOUT).map_err(|e| {
debug!(reason = %e, "issued credential: no HTTP client for the status list");
IssuedCredentialError::StatusUnknown
})?;
match check_credential_status(
status,
issuer,
async move |url: &str| fetch_owned(client.clone(), url.to_string()).await,
resolver,
now,
)
.await
{
StatusCheck::Active => Ok(()),
StatusCheck::Revoked => Err(IssuedCredentialError::Revoked),
StatusCheck::Unknown(reason) => {
debug!(%reason, "issued credential: status could not be established");
Err(IssuedCredentialError::StatusUnknown)
}
}
}
fn check_validity_window(
credential: &Value,
now: DateTime<Utc>,
) -> Result<(), IssuedCredentialError> {
let read = |key: &'static str| -> Result<Option<DateTime<Utc>>, IssuedCredentialError> {
match credential.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => DateTime::parse_from_rfc3339(s)
.map(|t| Some(t.with_timezone(&Utc)))
.map_err(|_| IssuedCredentialError::BadTimestamp(key)),
Some(_) => Err(IssuedCredentialError::BadTimestamp(key)),
}
};
let from = match read("validFrom")? {
Some(t) => Some(t),
None => read("issuanceDate")?,
};
let until = match read("validUntil")? {
Some(t) => Some(t),
None => read("expirationDate")?,
};
if from.is_some_and(|f| f > now + VALID_FROM_SKEW) {
return Err(IssuedCredentialError::NotYetValid);
}
if until.is_some_and(|u| now >= u) {
return Err(IssuedCredentialError::Expired);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const VTC: &str = "did:webvh:QmScid:vtc.example.com";
const PERSONA: &str = "did:webvh:QmP:example.com:alice";
fn credential() -> Value {
json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": VTC,
"validFrom": "2026-01-01T00:00:00Z",
"validUntil": "2099-01-01T00:00:00Z",
"credentialSubject": { "id": PERSONA, "role": "member" },
})
}
#[test]
fn the_validity_window_is_enforced() {
let now = Utc::now();
let mut vc = credential();
assert_eq!(check_validity_window(&vc, now), Ok(()));
vc["validUntil"] = json!("2020-01-01T00:00:00Z");
assert_eq!(
check_validity_window(&vc, now),
Err(IssuedCredentialError::Expired)
);
vc["validUntil"] = json!("2099-01-01T00:00:00Z");
vc["validFrom"] = json!("2098-01-01T00:00:00Z");
assert_eq!(
check_validity_window(&vc, now),
Err(IssuedCredentialError::NotYetValid)
);
vc["validFrom"] = json!("yesterday");
assert_eq!(
check_validity_window(&vc, now),
Err(IssuedCredentialError::BadTimestamp("validFrom"))
);
}
#[tokio::test]
async fn issuer_and_proof_presence_are_checked_locally() {
let resolver = DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.expect("resolver");
let now = Utc::now();
assert_eq!(
verify_issued_credential(
credential(),
"did:webvh:QmOther:evil.example.com",
&resolver,
now
)
.await
.unwrap_err(),
IssuedCredentialError::IssuerNotSender
);
assert_eq!(
verify_issued_credential(credential(), VTC, &resolver, now)
.await
.unwrap_err(),
IssuedCredentialError::Proof(ProofError::NoProof)
);
}
#[tokio::test]
async fn an_unreachable_status_list_refuses_the_credential() {
let resolver = DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.expect("resolver");
let mut key =
affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(None, Some(&[4; 32]));
let mb = key.get_public_keymultibase().unwrap();
let issuer = format!("did:key:{mb}");
key.id = format!("{issuer}#{mb}");
let mut vc = credential();
vc["issuer"] = json!(issuer);
vc["credentialStatus"] = json!({
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "7",
"statusListCredential": "https://127.0.0.1:1/status/revocation",
});
let vc = crate::proof_check::test_support::sign(vc, &[&key]).await;
let err = verify_issued_credential(vc, &issuer, &resolver, Utc::now())
.await
.unwrap_err();
assert_eq!(err, IssuedCredentialError::StatusUnknown);
assert!(err.to_string().contains("try again"), "{err}");
}
}