use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
#[cfg(feature = "webvh")]
use affinidi_data_integrity::{DataIntegrityProof, VerifyOptions, crypto_suites::CryptoSuite};
#[cfg(feature = "webvh")]
use affinidi_status_list::DEFAULT_BITSTRING_SIZE;
use affinidi_status_list::{BitstringStatusList, StatusPurpose};
use super::model::{CredentialStatus, StoredCredential};
use super::storage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusListRef {
pub status_list_credential: String,
pub status_list_index: usize,
pub status_purpose: Option<StatusPurpose>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedStatusList {
pub encoded_list: String,
pub size: usize,
pub status_purpose: StatusPurpose,
}
pub fn default_status_resolver(
did_resolver: Option<DIDCacheClient>,
) -> Option<std::sync::Arc<dyn StatusListResolver>> {
#[cfg(feature = "webvh")]
{
Some(std::sync::Arc::new(HttpStatusListResolver::new(
did_resolver,
)))
}
#[cfg(not(feature = "webvh"))]
{
let _ = did_resolver;
None
}
}
#[cfg(feature = "webvh")]
pub struct HttpStatusListResolver {
http: reqwest::Client,
did_resolver: Option<DIDCacheClient>,
}
#[cfg(feature = "webvh")]
impl HttpStatusListResolver {
pub fn new(did_resolver: Option<DIDCacheClient>) -> Self {
Self {
http: reqwest::Client::new(),
did_resolver,
}
}
}
#[cfg(feature = "webvh")]
#[async_trait::async_trait]
impl StatusListResolver for HttpStatusListResolver {
async fn resolve(
&self,
url: &str,
expected_issuer: Option<&str>,
) -> Result<ResolvedStatusList, AppError> {
let body: serde_json::Value = self
.http
.get(url)
.send()
.await
.map_err(|e| AppError::Internal(format!("status list fetch `{url}` failed: {e}")))?
.error_for_status()
.map_err(|e| AppError::Internal(format!("status list `{url}` returned an error: {e}")))?
.json()
.await
.map_err(|e| AppError::Internal(format!("status list `{url}` is not JSON: {e}")))?;
verify_status_list_signature(self.did_resolver.as_ref(), &body, expected_issuer, url)
.await?;
let subject = body.get("credentialSubject").ok_or_else(|| {
AppError::Validation(format!("status list `{url}` has no credentialSubject"))
})?;
let encoded_list = subject
.get("encodedList")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| AppError::Validation(format!("status list `{url}` has no encodedList")))?
.to_string();
let purpose_str = subject
.get("statusPurpose")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
AppError::Validation(format!("status list `{url}` has no statusPurpose"))
})?;
let status_purpose = parse_purpose(purpose_str).ok_or_else(|| {
AppError::Validation(format!(
"status list `{url}` has unknown statusPurpose `{purpose_str}`"
))
})?;
Ok(ResolvedStatusList {
encoded_list,
size: DEFAULT_BITSTRING_SIZE,
status_purpose,
})
}
}
#[cfg(feature = "webvh")]
async fn verify_status_list_signature(
did_resolver: Option<&DIDCacheClient>,
list_credential: &serde_json::Value,
expected_issuer: Option<&str>,
url: &str,
) -> Result<(), AppError> {
use crate::vault::di_verify::{credential_issuer, resolve_di_issuer_key};
let list_issuer = credential_issuer(list_credential).ok_or_else(|| {
AppError::Validation(format!("status list `{url}` has no `issuer` to verify"))
})?;
if let Some(expected) = expected_issuer
&& list_issuer != expected
{
return Err(AppError::Validation(format!(
"status list `{url}` issuer `{list_issuer}` is not the credential's issuer \
`{expected}` — refusing a substituted status list"
)));
}
let issuer_pub = resolve_di_issuer_key(did_resolver, list_credential).await?;
let proof_val = list_credential.get("proof").cloned().ok_or_else(|| {
AppError::Validation(format!("status list `{url}` has no `proof` to verify"))
})?;
let proof: DataIntegrityProof = serde_json::from_value(proof_val).map_err(|e| {
AppError::Validation(format!("status list `{url}` has an unparseable proof: {e}"))
})?;
if !matches!(proof.cryptosuite, CryptoSuite::EddsaJcs2022) {
return Err(AppError::Validation(format!(
"status list `{url}` proof cryptosuite {:?} is unsupported \
(expected eddsa-jcs-2022; BBS+ is audit-gated)",
proof.cryptosuite
)));
}
let mut signing_doc = list_credential.clone();
signing_doc
.as_object_mut()
.ok_or_else(|| AppError::Validation(format!("status list `{url}` is not a JSON object")))?
.remove("proof");
proof
.verify_with_public_key(&signing_doc, &issuer_pub, VerifyOptions::new())
.map_err(|e| {
AppError::Validation(format!(
"status list `{url}` issuer signature verification failed: {e}"
))
})?;
Ok(())
}
#[async_trait::async_trait]
pub trait StatusListResolver: Send + Sync {
async fn resolve(
&self,
url: &str,
expected_issuer: Option<&str>,
) -> Result<ResolvedStatusList, AppError>;
}
pub fn extract_status_ref(cred: &StoredCredential) -> Result<Option<StatusListRef>, AppError> {
let payload = decode_body_to_json(&cred.body)?;
if let Some(entry) = payload.get("credentialStatus") {
return parse_w3c_entry(entry).map(Some);
}
if let Some(sl) = payload.get("status").and_then(|s| s.get("status_list")) {
return parse_sd_jwt_entry(sl).map(Some);
}
Ok(None)
}
fn parse_w3c_entry(entry: &serde_json::Value) -> Result<StatusListRef, AppError> {
let status_list_credential = entry
.get("statusListCredential")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::Validation(
"credentialStatus is missing a non-empty `statusListCredential` URL".to_string(),
)
})?
.to_string();
let status_list_index = parse_index(entry.get("statusListIndex"))?;
let status_purpose = entry
.get("statusPurpose")
.and_then(|v| v.as_str())
.and_then(parse_purpose);
Ok(StatusListRef {
status_list_credential,
status_list_index,
status_purpose,
})
}
fn parse_sd_jwt_entry(sl: &serde_json::Value) -> Result<StatusListRef, AppError> {
let status_list_credential = sl
.get("uri")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
AppError::Validation("status.status_list is missing a non-empty `uri`".to_string())
})?
.to_string();
let status_list_index = parse_index(sl.get("idx"))?;
Ok(StatusListRef {
status_list_credential,
status_list_index,
status_purpose: None,
})
}
fn parse_index(v: Option<&serde_json::Value>) -> Result<usize, AppError> {
match v {
Some(serde_json::Value::String(s)) => s.parse::<usize>().map_err(|_| {
AppError::Validation(format!(
"statusListIndex `{s}` is not a non-negative integer"
))
}),
Some(serde_json::Value::Number(n)) => n
.as_u64()
.map(|u| u as usize)
.ok_or_else(|| AppError::Validation("statusListIndex is not a u64".to_string())),
_ => Err(AppError::Validation(
"credentialStatus is missing a `statusListIndex`".to_string(),
)),
}
}
fn parse_purpose(s: &str) -> Option<StatusPurpose> {
match s {
"revocation" => Some(StatusPurpose::Revocation),
"suspension" => Some(StatusPurpose::Suspension),
_ => None,
}
}
fn decode_body_to_json(body: &[u8]) -> Result<serde_json::Value, AppError> {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body)
&& v.is_object()
{
return Ok(v);
}
let text = std::str::from_utf8(body)
.map_err(|_| AppError::Validation("credential body is not UTF-8".to_string()))?;
let jws = text.split('~').next().unwrap_or(text);
let mut parts = jws.split('.');
let _header = parts.next();
let payload = parts
.next()
.ok_or_else(|| AppError::Validation("credential body is not a compact JWS".to_string()))?;
use base64::Engine;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.map_err(|e| AppError::Validation(format!("JWS payload is not base64url: {e}")))?;
serde_json::from_slice(&bytes)
.map_err(|e| AppError::Validation(format!("JWS payload is not JSON: {e}")))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RefreshOutcome {
NotTracked,
Refreshed {
previous: CredentialStatus,
current: CredentialStatus,
},
}
pub async fn refresh_status<R: StatusListResolver + ?Sized>(
vault: &KeyspaceHandle,
id: &str,
resolver: &R,
) -> Result<RefreshOutcome, AppError> {
let mut cred = storage::get(vault, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("no stored credential with id `{id}`")))?;
if !cred.is_active() {
return Ok(RefreshOutcome::NotTracked);
}
let Some(status_ref) = extract_status_ref(&cred)? else {
return Ok(RefreshOutcome::NotTracked);
};
let resolved = resolver
.resolve(
&status_ref.status_list_credential,
cred.issuer_did.as_deref(),
)
.await?;
if let Some(entry_purpose) = status_ref.status_purpose
&& entry_purpose != resolved.status_purpose
{
return Err(AppError::Validation(format!(
"status entry purpose {entry_purpose:?} does not match the resolved status \
list purpose {:?}; refusing to apply",
resolved.status_purpose
)));
}
let list = BitstringStatusList::decode(
&resolved.encoded_list,
resolved.size,
resolved.status_purpose,
)
.map_err(|e| AppError::Validation(format!("status list failed to decode: {e}")))?;
let bit_set = list.get(status_ref.status_list_index).map_err(|e| {
AppError::Validation(format!(
"statusListIndex {} is out of bounds for the resolved list: {e}",
status_ref.status_list_index
))
})?;
let previous = cred.status;
let new_status = next_status(previous, bit_set, resolved.status_purpose);
if new_status != previous {
cred.status = new_status;
storage::put(vault, &cred).await?;
}
Ok(RefreshOutcome::Refreshed {
previous,
current: new_status,
})
}
fn next_status(
previous: CredentialStatus,
bit_set: bool,
purpose: StatusPurpose,
) -> CredentialStatus {
if bit_set {
return CredentialStatus::Revoked;
}
match previous {
CredentialStatus::Expired => CredentialStatus::Expired,
CredentialStatus::Revoked if purpose == StatusPurpose::Revocation => {
CredentialStatus::Revoked
}
_ => CredentialStatus::Valid,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::model::{CredentialFormat, CredentialPurpose};
use crate::vault::query::{CredentialQuery, search};
use crate::vault::storage::put;
use std::collections::BTreeMap;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn fresh_vault() -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let ks = store
.keyspace(crate::keyspaces::VAULT)
.expect("vault keyspace");
(dir, store, ks)
}
const LIST_SIZE: usize = 1024;
fn encoded_list_with(set_index: Option<usize>, purpose: StatusPurpose) -> String {
let mut list = BitstringStatusList::new(LIST_SIZE, purpose);
if let Some(i) = set_index {
list.set(i, true).unwrap();
}
list.encode().unwrap()
}
struct MockResolver {
list: ResolvedStatusList,
calls: std::sync::atomic::AtomicUsize,
}
impl MockResolver {
fn new(set_index: Option<usize>, purpose: StatusPurpose) -> Self {
MockResolver {
list: ResolvedStatusList {
encoded_list: encoded_list_with(set_index, purpose),
size: LIST_SIZE,
status_purpose: purpose,
},
calls: std::sync::atomic::AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl StatusListResolver for MockResolver {
async fn resolve(
&self,
_url: &str,
_expected_issuer: Option<&str>,
) -> Result<ResolvedStatusList, AppError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(self.list.clone())
}
}
struct ErrResolver;
#[async_trait::async_trait]
impl StatusListResolver for ErrResolver {
async fn resolve(
&self,
_url: &str,
_expected_issuer: Option<&str>,
) -> Result<ResolvedStatusList, AppError> {
Err(AppError::Internal("status list unreachable".to_string()))
}
}
fn cred_with_status(id: &str, index: usize, purpose: &str) -> StoredCredential {
let body = serde_json::json!({
"vct": "https://openvtc.org/credentials/MembershipCredential",
"iss": "did:web:issuer.example",
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": purpose,
"statusListIndex": index.to_string(),
"statusListCredential": "https://issuer.example/status/1",
},
});
StoredCredential {
id: id.to_string(),
format: CredentialFormat::SdJwtVc,
types: vec!["MembershipCredential".into()],
schema_id: None,
community_did: Some("did:web:acme".into()),
subject_did: Some("did:key:zAlice".into()),
issuer_did: Some("did:web:issuer.example".into()),
purpose: Some(CredentialPurpose::Membership),
status: CredentialStatus::Valid,
valid_from: None,
valid_until: None,
received_at: "2026-06-03T00:00:00Z".into(),
source: None,
tags: BTreeMap::new(),
body: serde_json::to_vec(&body).unwrap(),
lifecycle: vti_common::vault::VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
}
}
fn cred_without_status(id: &str) -> StoredCredential {
let body = serde_json::json!({
"vct": "https://openvtc.org/credentials/MembershipCredential",
"iss": "did:web:issuer.example",
});
let mut c = cred_with_status(id, 0, "revocation");
c.body = serde_json::to_vec(&body).unwrap();
c
}
#[tokio::test]
async fn set_bit_marks_revoked_and_excludes_from_search() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-revoked", 42, "revocation");
put(&vault, &cred).await.unwrap();
let q = CredentialQuery {
issuer_did: Some("did:web:issuer.example".into()),
..Default::default()
};
assert_eq!(search(&vault, &q).await.unwrap().len(), 1);
let resolver = MockResolver::new(Some(42), StatusPurpose::Revocation);
let outcome = refresh_status(&vault, "cred-revoked", &resolver)
.await
.unwrap();
assert_eq!(
outcome,
RefreshOutcome::Refreshed {
previous: CredentialStatus::Valid,
current: CredentialStatus::Revoked,
}
);
assert_eq!(resolver.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
let stored = storage::get(&vault, "cred-revoked").await.unwrap().unwrap();
assert_eq!(stored.status, CredentialStatus::Revoked);
let hits = search(&vault, &q).await.unwrap();
assert!(
hits.is_empty(),
"a revoked credential must not be surfaced by search, got {hits:?}"
);
}
#[tokio::test]
async fn clear_bit_leaves_valid_and_findable() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-ok", 7, "revocation");
put(&vault, &cred).await.unwrap();
let resolver = MockResolver::new(Some(99), StatusPurpose::Revocation);
let outcome = refresh_status(&vault, "cred-ok", &resolver).await.unwrap();
assert_eq!(
outcome,
RefreshOutcome::Refreshed {
previous: CredentialStatus::Valid,
current: CredentialStatus::Valid,
}
);
let stored = storage::get(&vault, "cred-ok").await.unwrap().unwrap();
assert_eq!(stored.status, CredentialStatus::Valid);
let q = CredentialQuery {
issuer_did: Some("did:web:issuer.example".into()),
..Default::default()
};
let hits = search(&vault, &q).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "cred-ok");
}
#[tokio::test]
async fn empty_list_leaves_valid() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-ok2", 500, "revocation");
put(&vault, &cred).await.unwrap();
let resolver = MockResolver::new(None, StatusPurpose::Revocation);
refresh_status(&vault, "cred-ok2", &resolver).await.unwrap();
let stored = storage::get(&vault, "cred-ok2").await.unwrap().unwrap();
assert_eq!(stored.status, CredentialStatus::Valid);
}
#[tokio::test]
async fn credential_without_status_is_not_tracked() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_without_status("cred-plain");
put(&vault, &cred).await.unwrap();
let resolver = MockResolver::new(Some(0), StatusPurpose::Revocation);
let outcome = refresh_status(&vault, "cred-plain", &resolver)
.await
.unwrap();
assert_eq!(outcome, RefreshOutcome::NotTracked);
assert_eq!(resolver.calls.load(std::sync::atomic::Ordering::SeqCst), 0);
let stored = storage::get(&vault, "cred-plain").await.unwrap().unwrap();
assert_eq!(stored.status, CredentialStatus::Valid);
}
#[tokio::test]
async fn resolver_error_leaves_status_unchanged() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-x", 1, "revocation");
put(&vault, &cred).await.unwrap();
let err = refresh_status(&vault, "cred-x", &ErrResolver)
.await
.unwrap_err();
assert!(matches!(err, AppError::Internal(_)));
let stored = storage::get(&vault, "cred-x").await.unwrap().unwrap();
assert_eq!(stored.status, CredentialStatus::Valid);
}
#[tokio::test]
async fn missing_credential_is_not_found() {
let (_dir, _store, vault) = fresh_vault();
let resolver = MockResolver::new(None, StatusPurpose::Revocation);
let err = refresh_status(&vault, "nope", &resolver).await.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)));
}
#[tokio::test]
async fn index_out_of_bounds_is_rejected_and_status_unchanged() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-oob", LIST_SIZE + 10, "revocation");
put(&vault, &cred).await.unwrap();
let resolver = MockResolver::new(None, StatusPurpose::Revocation);
let err = refresh_status(&vault, "cred-oob", &resolver)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
let stored = storage::get(&vault, "cred-oob").await.unwrap().unwrap();
assert_eq!(stored.status, CredentialStatus::Valid);
}
#[tokio::test]
async fn suspension_set_then_clear_round_trips_valid() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-susp", 3, "suspension");
put(&vault, &cred).await.unwrap();
let suspend = MockResolver::new(Some(3), StatusPurpose::Suspension);
refresh_status(&vault, "cred-susp", &suspend).await.unwrap();
assert_eq!(
storage::get(&vault, "cred-susp")
.await
.unwrap()
.unwrap()
.status,
CredentialStatus::Revoked
);
let restore = MockResolver::new(None, StatusPurpose::Suspension);
refresh_status(&vault, "cred-susp", &restore).await.unwrap();
assert_eq!(
storage::get(&vault, "cred-susp")
.await
.unwrap()
.unwrap()
.status,
CredentialStatus::Valid
);
}
#[test]
fn revocation_is_terminal_but_suspension_is_reversible() {
assert_eq!(
next_status(CredentialStatus::Revoked, false, StatusPurpose::Revocation),
CredentialStatus::Revoked
);
assert_eq!(
next_status(CredentialStatus::Revoked, false, StatusPurpose::Suspension),
CredentialStatus::Valid
);
assert_eq!(
next_status(CredentialStatus::Valid, true, StatusPurpose::Revocation),
CredentialStatus::Revoked
);
}
#[test]
fn clear_bit_does_not_resurrect_a_time_expired_credential() {
assert_eq!(
next_status(CredentialStatus::Expired, false, StatusPurpose::Revocation),
CredentialStatus::Expired
);
assert_eq!(
next_status(CredentialStatus::Expired, false, StatusPurpose::Suspension),
CredentialStatus::Expired
);
assert_eq!(
next_status(CredentialStatus::Expired, true, StatusPurpose::Revocation),
CredentialStatus::Revoked
);
}
#[tokio::test]
async fn entry_purpose_mismatch_is_rejected_and_status_unchanged() {
let (_dir, _store, vault) = fresh_vault();
let cred = cred_with_status("cred-mismatch", 5, "revocation");
put(&vault, &cred).await.unwrap();
let resolver = MockResolver::new(Some(5), StatusPurpose::Suspension);
let err = refresh_status(&vault, "cred-mismatch", &resolver)
.await
.unwrap_err();
assert!(
matches!(err, AppError::Validation(_)),
"a status-list whose purpose differs from the entry's declared purpose \
must be rejected, got {err:?}"
);
let stored = storage::get(&vault, "cred-mismatch")
.await
.unwrap()
.unwrap();
assert_eq!(stored.status, CredentialStatus::Valid);
}
#[test]
fn extract_reads_sd_jwt_ietf_status_list_shape() {
let body = serde_json::json!({
"vct": "x",
"status": { "status_list": { "idx": 12, "uri": "https://issuer.example/sl" } },
});
let mut c = cred_with_status("c", 0, "revocation");
c.body = serde_json::to_vec(&body).unwrap();
let r = extract_status_ref(&c).unwrap().unwrap();
assert_eq!(r.status_list_credential, "https://issuer.example/sl");
assert_eq!(r.status_list_index, 12);
}
#[test]
fn extract_reads_compact_jws_body() {
use base64::Engine;
let payload = serde_json::json!({
"vct": "x",
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "88",
"statusListCredential": "https://issuer.example/sl",
},
});
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let header = b64(br#"{"alg":"EdDSA"}"#);
let payload_b64 = b64(&serde_json::to_vec(&payload).unwrap());
let compact = format!("{header}.{payload_b64}.sig~disclosure~");
let mut c = cred_with_status("c", 0, "revocation");
c.body = compact.into_bytes();
let r = extract_status_ref(&c).unwrap().unwrap();
assert_eq!(r.status_list_credential, "https://issuer.example/sl");
assert_eq!(r.status_list_index, 88);
assert_eq!(r.status_purpose, Some(StatusPurpose::Revocation));
}
#[test]
fn malformed_status_entry_fails_closed() {
let body = serde_json::json!({
"vct": "x",
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusListIndex": "1",
},
});
let mut c = cred_with_status("c", 0, "revocation");
c.body = serde_json::to_vec(&body).unwrap();
let err = extract_status_ref(&c).unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[cfg(feature = "webvh")]
mod signature {
use super::*;
use affinidi_crypto::did_key::ed25519_pub_to_did_key;
use affinidi_data_integrity::{
DataIntegrityProof as DiProof, SignOptions, crypto_suites::CryptoSuite as Suite,
};
use affinidi_secrets_resolver::secrets::Secret;
async fn signed_status_list(seed: u8, purpose: &str) -> (serde_json::Value, String) {
let probe = Secret::generate_ed25519(None, Some(&[seed; 32]));
let pub_bytes: [u8; 32] = probe.get_public_bytes().try_into().unwrap();
let issuer_did = ed25519_pub_to_did_key(&pub_bytes);
let vm_id = format!("{issuer_did}#key-0");
let secret = Secret::generate_ed25519(Some(&vm_id), Some(&[seed; 32]));
let encoded = encoded_list_with(Some(7), parse_purpose(purpose).unwrap());
let mut cred = serde_json::json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "BitstringStatusListCredential"],
"issuer": issuer_did,
"credentialSubject": {
"type": "BitstringStatusList",
"statusPurpose": purpose,
"encodedList": encoded,
},
});
let proof = DiProof::sign(
&cred,
&secret,
SignOptions::new()
.with_proof_purpose("assertionMethod")
.with_cryptosuite(Suite::EddsaJcs2022),
)
.await
.expect("sign status list");
cred["proof"] = serde_json::to_value(&proof).unwrap();
(cred, issuer_did)
}
#[tokio::test]
async fn valid_signature_and_matching_issuer_passes() {
let (cred, issuer) = signed_status_list(11, "revocation").await;
verify_status_list_signature(None, &cred, Some(&issuer), "https://x/sl")
.await
.expect("a correctly-signed, issuer-matched list must verify");
}
#[tokio::test]
async fn no_expected_issuer_still_verifies_the_signature() {
let (cred, _issuer) = signed_status_list(12, "revocation").await;
verify_status_list_signature(None, &cred, None, "https://x/sl")
.await
.expect("signature must still verify when binding is skipped");
}
#[tokio::test]
async fn substituted_issuer_is_rejected() {
let (cred, _issuer) = signed_status_list(13, "revocation").await;
let err = verify_status_list_signature(
None,
&cred,
Some("did:key:zStranger"),
"https://x/sl",
)
.await
.expect_err("a list whose issuer != the credential's must be refused");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
}
#[tokio::test]
async fn tampered_list_fails_the_signature() {
let (mut cred, issuer) = signed_status_list(14, "revocation").await;
cred["credentialSubject"]["encodedList"] =
serde_json::json!(encoded_list_with(None, StatusPurpose::Revocation));
let err = verify_status_list_signature(None, &cred, Some(&issuer), "https://x/sl")
.await
.expect_err("a tampered list must fail signature verification");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
}
#[tokio::test]
async fn unsigned_list_is_rejected() {
let (mut cred, issuer) = signed_status_list(15, "revocation").await;
cred.as_object_mut().unwrap().remove("proof");
let err = verify_status_list_signature(None, &cred, Some(&issuer), "https://x/sl")
.await
.expect_err("an unsigned list must be refused");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
}
}
}