use affinidi_data_integrity::{DataIntegrityProof, SignOptions, crypto_suites::CryptoSuite};
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::error::AppError;
use crate::operations::internal_authority::InternalAuthority;
use crate::server::AppState;
use crate::store::KeyspaceHandle;
use vta_sdk::did_key::decode_private_key_multibase;
use vta_sdk::protocols::credentials_issuance::{
IssuedCredentialStatus, IssuedCredentialSummary, ListCredentialsBody, ListCredentialsResponse,
};
const VC_V2_CONTEXT: &str = "https://www.w3.org/ns/credentials/v2";
pub(crate) const CRED_KEY_PREFIX: &str = "cred:";
fn store_key(id: &str) -> String {
format!("{CRED_KEY_PREFIX}{id}")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuedCredentialRecord {
pub id: String,
pub holder: String,
pub credential: Value,
pub issued_at: String,
pub expires_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revocation_reason: Option<String>,
}
impl IssuedCredentialRecord {
fn is_revoked(&self) -> bool {
self.revoked_at.is_some()
}
}
pub struct IssueParams<'a> {
pub holder: &'a str,
pub claims: &'a Value,
pub credential_type: Option<&'a str>,
pub validity_seconds: u64,
}
pub async fn issue_credential(
state: &AppState,
params: IssueParams<'_>,
) -> Result<IssuedCredentialRecord, AppError> {
let claims_obj = params
.claims
.as_object()
.ok_or_else(|| AppError::Validation("claims must be a JSON object".to_string()))?;
if claims_obj.is_empty() {
return Err(AppError::Validation(
"claims must be a non-empty object".to_string(),
));
}
if params.validity_seconds == 0 {
return Err(AppError::Validation(
"validitySeconds must be greater than zero".to_string(),
));
}
let vta_did =
state.config.read().await.vta_did.clone().ok_or_else(|| {
AppError::Internal("VTA DID not configured; cannot issue".to_string())
})?;
let issuer_secret = load_vta_issuer_secret(state, &vta_did, "credentials-issue").await?;
let now = Utc::now();
let expires = now + Duration::seconds(params.validity_seconds as i64);
let id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let mut subject = claims_obj.clone();
subject.insert("id".to_string(), Value::String(params.holder.to_string()));
let mut types = vec![Value::String("VerifiableCredential".to_string())];
if let Some(ct) = params.credential_type {
types.push(Value::String(ct.to_string()));
}
let mut vc = json!({
"@context": [VC_V2_CONTEXT],
"id": id,
"type": types,
"issuer": vta_did,
"validFrom": rfc3339(now),
"validUntil": rfc3339(expires),
"credentialSubject": Value::Object(subject),
});
let proof = DataIntegrityProof::sign(
&vc,
&issuer_secret,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await
.map_err(|e| AppError::Internal(format!("sign issued credential: {e}")))?;
vc.as_object_mut().expect("vc is an object").insert(
"proof".to_string(),
serde_json::to_value(&proof)
.map_err(|e| AppError::Internal(format!("serialize issued-credential proof: {e}")))?,
);
let record = IssuedCredentialRecord {
id: id.clone(),
holder: params.holder.to_string(),
credential: vc,
issued_at: rfc3339(now),
expires_at: rfc3339(expires),
revoked_at: None,
revocation_reason: None,
};
store_put(&state.issued_credentials_ks, &record).await?;
Ok(record)
}
pub async fn revoke_credential(
state: &AppState,
credential_id: &str,
reason: Option<&str>,
) -> Result<String, AppError> {
let mut record = store_get(&state.issued_credentials_ks, credential_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("credential {credential_id} not found")))?;
if record.is_revoked() {
return Err(AppError::Conflict(format!(
"credential {credential_id} is already revoked"
)));
}
let revoked_at = rfc3339(Utc::now());
record.revoked_at = Some(revoked_at.clone());
record.revocation_reason = reason.map(str::to_string);
store_put(&state.issued_credentials_ks, &record).await?;
Ok(revoked_at)
}
pub(crate) async fn load_vta_issuer_secret(
state: &AppState,
vta_did: &str,
purpose: &'static str,
) -> Result<Secret, AppError> {
let key_id = format!("{vta_did}#key-0");
let authority = InternalAuthority::new(purpose);
let resp = crate::operations::keys::get_key_secret_internal(
&state.keys_ks,
&state.imported_ks,
&*state.seed_store,
&state.audit_sink,
authority,
&key_id,
purpose,
)
.await?;
let _seed: [u8; 32] = decode_private_key_multibase(&resp.private_key_multibase)
.map_err(|e| AppError::Internal(format!("decode VTA issuer key {key_id}: {e}")))?;
let mut secret = Secret::from_multibase(&resp.private_key_multibase, None)
.map_err(|e| AppError::Internal(format!("construct issuer Secret for {key_id}: {e}")))?;
secret.id = key_id;
Ok(secret)
}
async fn store_put(ks: &KeyspaceHandle, record: &IssuedCredentialRecord) -> Result<(), AppError> {
ks.insert(store_key(&record.id), record).await
}
async fn store_get(
ks: &KeyspaceHandle,
id: &str,
) -> Result<Option<IssuedCredentialRecord>, AppError> {
ks.get(store_key(id)).await
}
fn rfc3339(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn summarise(record: &IssuedCredentialRecord, now: DateTime<Utc>) -> IssuedCredentialSummary {
IssuedCredentialSummary {
credential_id: record.id.clone(),
holder: record.holder.clone(),
credential_type: credential_type_of(&record.credential),
issued_at: record.issued_at.clone(),
expires_at: record.expires_at.clone(),
status: status_of(record, now),
revoked_at: record.revoked_at.clone(),
revocation_reason: record.revocation_reason.clone(),
}
}
fn credential_type_of(credential: &Value) -> Option<String> {
credential
.get("type")?
.as_array()?
.iter()
.filter_map(Value::as_str)
.find(|t| *t != "VerifiableCredential")
.map(str::to_string)
}
fn status_of(record: &IssuedCredentialRecord, now: DateTime<Utc>) -> IssuedCredentialStatus {
if record.is_revoked() {
return IssuedCredentialStatus::Revoked;
}
match DateTime::parse_from_rfc3339(&record.expires_at) {
Ok(expires) if expires.with_timezone(&Utc) <= now => IssuedCredentialStatus::Expired,
_ => IssuedCredentialStatus::Active,
}
}
const LIST_PAGE_MAX: usize = 200;
const LIST_PAGE_DEFAULT: usize = 50;
pub async fn list_issued(
state: &AppState,
filter: &ListCredentialsBody,
) -> Result<ListCredentialsResponse, AppError> {
let now = Utc::now();
let limit = filter
.page_size
.map_or(LIST_PAGE_DEFAULT, |n| (n as usize).min(LIST_PAGE_MAX))
.max(1);
let mut rows: Vec<(String, IssuedCredentialSummary)> = Vec::new();
for (raw_key, bytes) in state
.issued_credentials_ks
.prefix_iter_raw(CRED_KEY_PREFIX.as_bytes().to_vec())
.await?
{
let record: IssuedCredentialRecord = serde_json::from_slice(&bytes)
.map_err(|e| AppError::Internal(format!("decode issued-credential record: {e}")))?;
let summary = summarise(&record, now);
if let Some(h) = &filter.holder
&& summary.holder != *h
{
continue;
}
if let Some(t) = &filter.credential_type
&& summary.credential_type.as_deref() != Some(t.as_str())
{
continue;
}
if let Some(s) = filter.status
&& summary.status != s
{
continue;
}
rows.push((String::from_utf8_lossy(&raw_key).into_owned(), summary));
}
Ok(page_rows(rows, filter.cursor.as_deref(), limit))
}
fn page_rows(
mut rows: Vec<(String, IssuedCredentialSummary)>,
cursor: Option<&str>,
limit: usize,
) -> ListCredentialsResponse {
rows.sort_by(|a, b| a.0.cmp(&b.0));
if let Some(cursor) = cursor {
rows.retain(|(k, _)| k.as_str() > cursor);
}
let truncated = rows.len() > limit;
rows.truncate(limit);
let cursor = truncated
.then(|| rows.last().map(|(k, _)| k.clone()))
.flatten();
ListCredentialsResponse {
credentials: rows.into_iter().map(|(_, s)| s).collect(),
truncated,
cursor,
ext: None,
}
}
#[cfg(test)]
mod list_tests {
use super::*;
fn record(id: &str, expires: &str, revoked: Option<&str>) -> IssuedCredentialRecord {
IssuedCredentialRecord {
id: id.into(),
holder: "did:key:zHolder".into(),
credential: serde_json::json!({
"type": ["VerifiableCredential", "MembershipCredential"],
}),
issued_at: "2026-01-01T00:00:00Z".into(),
expires_at: expires.into(),
revoked_at: revoked.map(str::to_string),
revocation_reason: revoked.map(|_| "role ended".to_string()),
}
}
fn now() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn revoked_takes_precedence_over_expired() {
let r = record("a", "2026-02-01T00:00:00Z", Some("2026-01-15T00:00:00Z"));
assert_eq!(status_of(&r, now()), IssuedCredentialStatus::Revoked);
}
#[test]
fn expiry_is_measured_against_the_clock() {
assert_eq!(
status_of(&record("a", "2026-02-01T00:00:00Z", None), now()),
IssuedCredentialStatus::Expired
);
assert_eq!(
status_of(&record("a", "2027-01-01T00:00:00Z", None), now()),
IssuedCredentialStatus::Active
);
}
#[test]
fn an_unreadable_expiry_reads_as_active_not_expired() {
let r = record("a", "not-a-timestamp", None);
assert_eq!(status_of(&r, now()), IssuedCredentialStatus::Active);
}
#[test]
fn the_type_tag_skips_the_base_type() {
let r = record("a", "2027-01-01T00:00:00Z", None);
assert_eq!(
credential_type_of(&r.credential).as_deref(),
Some("MembershipCredential")
);
}
#[test]
fn a_credential_with_only_the_base_type_has_none() {
let mut r = record("a", "2027-01-01T00:00:00Z", None);
r.credential = serde_json::json!({ "type": ["VerifiableCredential"] });
assert_eq!(credential_type_of(&r.credential), None);
}
#[test]
fn a_summary_never_carries_the_credential() {
let r = record("a", "2027-01-01T00:00:00Z", None);
let wire = serde_json::to_value(summarise(&r, now())).unwrap();
assert!(wire.get("credential").is_none());
assert!(!wire.to_string().contains("VerifiableCredential"));
}
fn rows(ids: &[&str]) -> Vec<(String, IssuedCredentialSummary)> {
ids.iter()
.map(|id| {
(
format!("cred:{id}"),
summarise(&record(id, "2027-01-01T00:00:00Z", None), now()),
)
})
.collect()
}
#[test]
fn a_short_page_is_not_truncated_and_offers_no_cursor() {
let page = page_rows(rows(&["a", "b"]), None, 10);
assert_eq!(page.credentials.len(), 2);
assert!(!page.truncated);
assert_eq!(page.cursor, None);
}
#[test]
fn a_full_page_reports_truncation_and_the_key_to_resume_after() {
let page = page_rows(rows(&["a", "b", "c"]), None, 2);
assert_eq!(page.credentials.len(), 2);
assert!(page.truncated);
assert_eq!(page.cursor.as_deref(), Some("cred:b"));
}
#[test]
fn a_cursor_resumes_strictly_after_it() {
let page = page_rows(rows(&["a", "b", "c"]), Some("cred:b"), 10);
let ids: Vec<_> = page
.credentials
.iter()
.map(|c| c.credential_id.as_str())
.collect();
assert_eq!(ids, ["c"], "the cursor row itself must not repeat");
}
#[test]
fn a_row_issued_mid_walk_does_not_displace_an_unseen_one() {
let page_one = page_rows(rows(&["a", "b", "c"]), None, 2);
assert_eq!(page_one.cursor.as_deref(), Some("cred:b"));
let page_two = page_rows(rows(&["a0", "a", "b", "c"]), page_one.cursor.as_deref(), 2);
let ids: Vec<_> = page_two
.credentials
.iter()
.map(|c| c.credential_id.as_str())
.collect();
assert_eq!(ids, ["c"]);
}
#[test]
fn ordering_is_stable_regardless_of_scan_order() {
let forward = page_rows(rows(&["a", "b", "c"]), None, 10);
let reversed = page_rows(rows(&["c", "b", "a"]), None, 10);
let ids = |p: &ListCredentialsResponse| {
p.credentials
.iter()
.map(|c| c.credential_id.clone())
.collect::<Vec<_>>()
};
assert_eq!(ids(&forward), ids(&reversed));
}
}