use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_rs::{RejectReason, TrustTask};
use uuid::Uuid;
use vti_common::acl::Capability;
use vti_common::vault::{LifecycleError, VaultStatus};
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::server::AppState;
use crate::vault::model::{CredentialPurpose, CredentialStatus};
use crate::vault::query::{CredentialDescriptor, CredentialQuery, search};
use crate::vault::{receive, storage};
use super::helpers::{
TrustTaskOutcome, app_error_to_reject, parse_payload, reject_with, success_response,
};
async fn require_cap(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
cap: Capability,
action: &str,
) -> Result<(), TrustTaskOutcome> {
super::helpers::require_capability(state, auth, doc, cap, &format!("credential-vault {action}"))
.await
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ReceiveBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) context_id: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ReceiveResponse {
pub(super) id: String,
pub(super) types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) purpose: Option<CredentialPurpose>,
pub(super) status: CredentialStatus,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct QueryResponse {
pub(super) credentials: Vec<CredentialDescriptor>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct GetBody {
pub(super) id: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct GetResponse {
pub(super) credential: Value,
}
pub(super) async fn handle_receive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::VaultWrite, "receive").await {
return r;
}
let req: ReceiveBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let custody_context = match resolve_custody_context(auth, req.context_id) {
Ok(c) => c,
Err(e) => return app_error_to_reject(&doc, e),
};
let now = Utc::now();
let provenance = Some("vault/credentials/receive/0.1".to_string());
let mut stored = match req.format.as_deref() {
None | Some("ldp_vc") => {
let Some(credential) = req.credential else {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "a Data-Integrity credential must be supplied as `credential`"
.to_string(),
},
);
};
if req.credential_base64.is_some() {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "supply exactly one of `credential` or `credentialBase64`"
.to_string(),
},
);
}
let id = resolve_storage_id(req.id, &credential);
let resolver = state.trust_task_vm_resolver();
let body = match serde_json::to_vec(&credential) {
Ok(b) => b,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("credential serialise: {e}"),
},
);
}
};
match receive::receive_di_vc(&state.vault_ks, &id, &body, &resolver, provenance, now)
.await
{
Ok(s) => s,
Err(e) => return app_error_to_reject(&doc, e),
}
}
Some("mso_mdoc") => {
let Some(b64) = req.credential_base64 else {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "an mdoc must be supplied as `credentialBase64` (CBOR \
IssuerSigned), not `credential`"
.to_string(),
},
);
};
if req.credential.is_some() {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "supply exactly one of `credential` or `credentialBase64`"
.to_string(),
},
);
}
let body = match URL_SAFE_NO_PAD.decode(b64.as_bytes()) {
Ok(b) => b,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("`credentialBase64` is not base64url-no-pad: {e}"),
},
);
}
};
let issued = match affinidi_mdoc::IssuerSigned::from_cbor_bytes(&body) {
Ok(i) => i,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("not a decodable mdoc IssuerSigned: {e}"),
},
);
}
};
let issuer_pub = match state
.mdoc_trust
.resolve_issuer_key(&issued.issuer_auth, now)
{
Ok(k) => k,
Err(e) => return app_error_to_reject(&doc, e),
};
let device_point = match vta_vault::mdoc_trust::mdoc_device_key_sec1(&issued.mso) {
Ok(p) => p,
Err(e) => return app_error_to_reject(&doc, e),
};
let device_mb =
vta_keys::encode_public_multibase(&vta_sdk::keys::KeyType::P256, &device_point);
let device_key = match crate::operations::keys::find_key_by_public_multibase(
&state.keys_ks,
&device_mb,
)
.await
{
Ok(Some(k)) => k,
Ok(None) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: "this VTA does not hold the mdoc's MSO deviceKey, so the \
credential could never be presented with holder binding"
.to_string(),
},
);
}
Err(e) => return app_error_to_reject(&doc, e),
};
let key_gate = match device_key.context_id.as_deref() {
Some(ctx) => auth.require_context(ctx),
None => auth.require_super_admin(),
};
if let Err(e) = key_gate {
return app_error_to_reject(&doc, e);
}
let id = req
.id
.unwrap_or_else(|| format!("urn:uuid:{}", Uuid::new_v4()));
match receive::receive_mdoc(
&state.vault_ks,
&id,
&body,
&issuer_pub,
&device_key.key_id,
provenance,
now,
)
.await
{
Ok(s) => s,
Err(e) => return app_error_to_reject(&doc, e),
}
}
Some(other) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!(
"unsupported credential format `{other}` (expected `ldp_vc` or \
`mso_mdoc`)"
),
},
);
}
};
if custody_context.is_some() {
stored.context_id = custody_context;
if let Err(e) = storage::put(&state.vault_ks, &stored).await {
return app_error_to_reject(&doc, e);
}
}
success_response(
&doc,
ReceiveResponse {
id: stored.id,
types: stored.types,
purpose: stored.purpose,
status: stored.status,
},
)
}
pub(super) async fn handle_query(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::VaultRead, "query").await {
return r;
}
let query: CredentialQuery = match parse_payload(&doc) {
Ok(q) => q,
Err(resp) => return resp,
};
match search(&state.vault_ks, &query, &auth.act_scope()).await {
Ok(credentials) => success_response(&doc, QueryResponse { credentials }),
Err(e) => app_error_to_reject(&doc, e),
}
}
fn resolve_storage_id(explicit: Option<String>, credential: &Value) -> String {
explicit
.or_else(|| {
credential
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| format!("urn:uuid:{}", Uuid::new_v4()))
}
fn resolve_custody_context(
auth: &AuthClaims,
override_ctx: Option<String>,
) -> Result<Option<String>, AppError> {
match override_ctx {
Some(ctx) => {
if !auth.has_context_access(&ctx) {
return Err(AppError::Forbidden(format!(
"caller cannot receive a credential into context {ctx}"
)));
}
Ok(Some(ctx))
}
None if auth.allowed_contexts.len() == 1 => Ok(Some(auth.allowed_contexts[0].clone())),
None => Ok(None),
}
}
pub(super) async fn handle_get(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::VaultRead, "get").await {
return r;
}
let req: GetBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(stored))
if stored.is_active()
&& crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
stored.context_id.as_deref(),
) =>
{
match serde_json::from_slice::<Value>(&stored.body) {
Ok(credential) => success_response(&doc, GetResponse { credential }),
Err(e) => reject_with(
&doc,
RejectReason::InternalError {
reason: format!("stored credential body is not JSON: {e}"),
},
),
}
}
Ok(_) => reject_with(
&doc,
RejectReason::TaskFailed {
reason: "credential not found".to_string(),
details: None,
},
),
Err(e) => app_error_to_reject(&doc, e),
}
}
pub(super) async fn handle_present(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use crate::operations::credential_exchange::{PresentToError, present_to_audience};
use trust_tasks_rs::specs::vault::credentials::present::v0_1 as spec;
use trust_tasks_rs::specs::vault::credentials::present::v0_1::error_codes as code;
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialPresent, "present").await {
return r;
}
let req: spec::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let claims: Vec<String> = req.claims.iter().map(|c| c.to_string()).collect();
let refuse = |c: trust_tasks_rs::DeclaredErrorCode, message: &str, details: Option<Value>| {
super::helpers::reject_with_code(
&doc,
trust_tasks_rs::TrustTaskCode::Extended {
slug: c.namespace().to_string(),
local: c.local().to_string(),
},
message,
details,
)
};
match present_to_audience(
state,
auth,
&req.id,
&req.audience,
&req.nonce,
&claims,
req.purpose.as_deref().map(|p| p.as_str()),
Utc::now(),
)
.await
{
Ok(presented) => {
let response = serde_json::json!({
"presentation": presented.presentation,
"disclosed": presented.disclosed,
});
match serde_json::from_value::<spec::Response>(response) {
Ok(r) => success_response(&doc, r),
Err(e) => reject_with(
&doc,
RejectReason::InternalError {
reason: format!("presentation does not fit the response: {e}"),
},
),
}
}
Err(PresentToError::NotFound) => refuse(
code::NOT_FOUND,
"no credential under this id that you may present, or no key of its subject \
that you may use",
None,
),
Err(PresentToError::NotPresentable) => refuse(
code::NOT_PRESENTABLE,
"the credential is revoked, suspended, outside its validity period, archived or \
deleted",
None,
),
Err(PresentToError::FormatUnsupported) => refuse(
code::FORMAT_UNSUPPORTED,
"this agent presents SD-JWT-VC and Data Integrity credentials to a named verifier",
None,
),
Err(PresentToError::ClaimNotHeld(missing)) => refuse(
code::CLAIM_NOT_HELD,
"the credential does not carry every claim named",
Some(serde_json::json!({ "claims": missing })),
),
Err(PresentToError::WouldOverDisclose(undisclosed)) => refuse(
code::WOULD_OVER_DISCLOSE,
"every presentation of this credential discloses claims the request does not name; \
name them too, or present another credential",
Some(serde_json::json!({ "undisclosed": undisclosed })),
),
Err(PresentToError::App(e)) => app_error_to_reject(&doc, e),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct CredLifecycleBody {
pub(super) id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[allow(dead_code)] pub(super) reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct CredDeleteBody {
pub(super) id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[allow(dead_code)] pub(super) reason: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(super) force: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct CredLifecycleResponse {
pub(super) id: String,
pub(super) lifecycle: VaultStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) grace_until: Option<String>,
}
fn cred_not_found(doc: &TrustTask<Value>, verb: &str, id: &str) -> TrustTaskOutcome {
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/credentials/{verb}:not_found — no credential at id {id}"),
details: None,
},
)
}
fn cred_lifecycle_reject(
doc: &TrustTask<Value>,
verb: &str,
id: &str,
err: LifecycleError,
) -> TrustTaskOutcome {
let hint = match err {
LifecycleError::NotActive => "credential is not active (already archived or deleted)",
LifecycleError::NotArchived => "credential is not archived",
LifecycleError::AlreadyDeleted => {
"credential is already in the trash — restore it or purge it"
}
LifecycleError::NotDeleted => "credential is not in the trash",
LifecycleError::GraceExpired => {
"the grace window has elapsed — the credential has been (or is about to be) purged"
}
_ => "the credential's lifecycle state does not allow this",
};
reject_with(
doc,
RejectReason::TaskFailed {
reason: format!("vault/credentials/{verb}:{} — {hint} (id {id})", err.code()),
details: None,
},
)
}
fn cred_lifecycle_response(cred: &crate::vault::model::StoredCredential) -> CredLifecycleResponse {
CredLifecycleResponse {
id: cred.id.clone(),
lifecycle: cred.lifecycle,
grace_until: cred.grace_until.clone(),
}
}
pub(super) async fn handle_archive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let now = Utc::now().to_rfc3339();
cred_transition(state, auth, doc, "archive", move |cred| cred.archive(&now)).await
}
pub(super) async fn handle_unarchive(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
cred_transition(state, auth, doc, "unarchive", |cred| cred.unarchive()).await
}
pub(super) async fn handle_restore(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let now = Utc::now().to_rfc3339();
cred_transition(state, auth, doc, "restore", move |cred| cred.restore(&now)).await
}
async fn cred_transition(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
verb: &str,
transition: impl FnOnce(&mut crate::vault::model::StoredCredential) -> Result<(), LifecycleError>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialWrite, verb).await {
return r;
}
let req: CredLifecycleBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let mut cred = match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(c)) => c,
Ok(None) => return cred_not_found(&doc, verb, &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) {
return cred_not_found(&doc, verb, &req.id);
}
if let Err(e) = transition(&mut cred) {
return cred_lifecycle_reject(&doc, verb, &req.id, e);
}
if let Err(e) = storage::put(&state.vault_ks, &cred).await {
return app_error_to_reject(&doc, e);
}
success_response(&doc, cred_lifecycle_response(&cred))
}
pub(super) async fn handle_delete(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialWrite, "delete").await {
return r;
}
let req: CredDeleteBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if req.force {
match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(cred))
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) =>
{
return cred_not_found(&doc, "delete", &req.id);
}
Err(e) => return app_error_to_reject(&doc, e),
_ => {}
}
if let Err(e) = storage::delete(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
return success_response(
&doc,
CredLifecycleResponse {
id: req.id,
lifecycle: VaultStatus::Deleted,
grace_until: None,
},
);
}
let mut cred = match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(c)) => c,
Ok(None) => return cred_not_found(&doc, "delete", &req.id),
Err(e) => return app_error_to_reject(&doc, e),
};
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) {
return cred_not_found(&doc, "delete", &req.id);
}
let now = Utc::now();
let grace_days = state.config.read().await.vault.grace_days;
let grace_until = (now + chrono::Duration::days(grace_days as i64)).to_rfc3339();
if let Err(e) = cred.soft_delete(&now.to_rfc3339(), &grace_until) {
return cred_lifecycle_reject(&doc, "delete", &req.id, e);
}
if let Err(e) = storage::put(&state.vault_ks, &cred).await {
return app_error_to_reject(&doc, e);
}
success_response(&doc, cred_lifecycle_response(&cred))
}
pub(super) async fn handle_purge(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = require_cap(state, auth, &doc, Capability::CredentialWrite, "purge").await {
return r;
}
let req: CredLifecycleBody = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
match storage::get(&state.vault_ks, &req.id).await {
Ok(Some(cred)) => {
if !crate::vault::query::caller_may_access_custody(
&auth.act_scope(),
cred.context_id.as_deref(),
) {
return cred_not_found(&doc, "purge", &req.id);
}
}
Ok(None) => return cred_not_found(&doc, "purge", &req.id),
Err(e) => return app_error_to_reject(&doc, e),
}
if let Err(e) = storage::delete(&state.vault_ks, &req.id).await {
return app_error_to_reject(&doc, e);
}
success_response(
&doc,
CredLifecycleResponse {
id: req.id,
lifecycle: VaultStatus::Deleted,
grace_until: None,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn storage_id_prefers_explicit_then_vc_id_then_uuid() {
let vc = json!({ "id": "urn:uuid:from-vc", "type": ["InvitationCredential"] });
assert_eq!(
resolve_storage_id(Some("explicit-id".into()), &vc),
"explicit-id"
);
assert_eq!(resolve_storage_id(None, &vc), "urn:uuid:from-vc");
let generated = resolve_storage_id(None, &json!({ "type": ["X"] }));
assert!(
generated.starts_with("urn:uuid:"),
"fallback id is a urn:uuid: {generated}"
);
}
async fn seed_credential(
vault_ks: &crate::store::KeyspaceHandle,
id: &str,
context_id: Option<&str>,
) {
use crate::vault::model::{CredentialFormat, CredentialStatus, StoredCredential};
use vti_common::vault::VaultStatus;
let cred = StoredCredential {
id: id.into(),
format: CredentialFormat::EddsaJcs2022,
types: vec!["MembershipCredential".into()],
schema_id: None,
community_did: None,
context_id: context_id.map(str::to_string),
subject_did: None,
issuer_did: Some("did:key:zIssuer".into()),
purpose: None,
status: CredentialStatus::Unknown,
valid_from: None,
valid_until: None,
received_at: "2026-01-01T00:00:00Z".into(),
source: None,
tags: Default::default(),
body: serde_json::to_vec(&json!({"id": id})).unwrap(),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
};
crate::vault::storage::put(vault_ks, &cred).await.unwrap();
}
fn get_doc(id: &str) -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_GET_0_1
.parse()
.expect("get uri");
TrustTask::new("urn:uuid:test-get", uri, json!({ "id": id }))
}
fn query_doc() -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_QUERY_0_1
.parse()
.expect("query uri");
TrustTask::new(
"urn:uuid:test-query",
uri,
json!({ "issuerDid": "did:key:zIssuer" }),
)
}
#[tokio::test]
async fn get_refuses_a_credential_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let outcome = handle_get(&state, &auth, get_doc("cred-in-ctx-b")).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
!body.contains("cred-in-ctx-b") || body.contains("not found"),
"a ctx-a caller must not receive a ctx-b credential body; got {body}"
);
}
#[tokio::test]
async fn get_allows_a_credential_owned_by_the_callers_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-a", Some("ctx-a")).await;
let auth = auth_scoped(&["ctx-a"]);
let outcome = handle_get(&state, &auth, get_doc("cred-in-ctx-a")).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
body.contains("cred-in-ctx-a"),
"the caller's own context must still be readable; got {body}"
);
}
#[tokio::test]
async fn query_excludes_credentials_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-a", Some("ctx-a")).await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let outcome = handle_query(&state, &auth, query_doc()).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
body.contains("cred-in-ctx-a"),
"own-context credential must be returned; got {body}"
);
assert!(
!body.contains("cred-in-ctx-b"),
"another context's credential must not be returned; got {body}"
);
}
#[tokio::test]
async fn super_admin_still_reads_every_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-anywhere", Some("ctx-b")).await;
let auth = auth_scoped(&[]);
let outcome = handle_get(&state, &auth, get_doc("cred-anywhere")).await;
let body = String::from_utf8_lossy(&outcome.body).to_string();
assert!(
body.contains("cred-anywhere"),
"super admin must still read any context; got {body}"
);
}
fn lifecycle_doc(task: &str, id: &str, force: bool) -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = task.parse().expect("uri");
TrustTask::new(
"urn:uuid:test-lifecycle",
uri,
json!({ "id": id, "force": force }),
)
}
#[tokio::test]
async fn archive_refuses_a_credential_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let doc = lifecycle_doc(
vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_ARCHIVE_0_1,
"cred-in-ctx-b",
false,
);
let _ = handle_archive(&state, &auth, doc).await;
let after = crate::vault::storage::get(&state.vault_ks, "cred-in-ctx-b")
.await
.unwrap()
.expect("credential must survive");
assert!(
after.is_active(),
"a ctx-a caller must not archive a ctx-b credential"
);
}
#[tokio::test]
async fn force_delete_refuses_a_credential_owned_by_another_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-b", Some("ctx-b")).await;
let auth = auth_scoped(&["ctx-a"]);
let doc = lifecycle_doc(
vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_DELETE_0_1,
"cred-in-ctx-b",
true,
);
let _ = handle_delete(&state, &auth, doc).await;
assert!(
crate::vault::storage::get(&state.vault_ks, "cred-in-ctx-b")
.await
.unwrap()
.is_some(),
"a ctx-a caller must not hard-delete a ctx-b credential"
);
}
#[tokio::test]
async fn force_delete_allows_the_callers_own_context() {
let (state, _dir) = crate::test_support::build_signing_test_app_state().await;
seed_credential(&state.vault_ks, "cred-in-ctx-a", Some("ctx-a")).await;
let auth = auth_scoped(&["ctx-a"]);
let doc = lifecycle_doc(
vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_DELETE_0_1,
"cred-in-ctx-a",
true,
);
let _ = handle_delete(&state, &auth, doc).await;
assert!(
crate::vault::storage::get(&state.vault_ks, "cred-in-ctx-a")
.await
.unwrap()
.is_none(),
"the caller's own credential must still be deletable"
);
}
fn auth_scoped(ctxs: &[&str]) -> AuthClaims {
AuthClaims {
role: crate::acl::Role::Admin,
allowed_contexts: ctxs.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn custody_auto_binds_to_callers_single_context() {
let r = resolve_custody_context(&auth_scoped(&["acme"]), None).unwrap();
assert_eq!(r.as_deref(), Some("acme"));
}
#[test]
fn custody_unscoped_for_super_admin_and_multi_context() {
assert_eq!(
resolve_custody_context(&auth_scoped(&[]), None).unwrap(),
None
);
assert_eq!(
resolve_custody_context(&auth_scoped(&["a", "b"]), None).unwrap(),
None
);
}
#[test]
fn custody_override_must_be_accessible() {
let ok = resolve_custody_context(&auth_scoped(&["acme"]), Some("acme".into())).unwrap();
assert_eq!(ok.as_deref(), Some("acme"));
let sa = resolve_custody_context(&auth_scoped(&[]), Some("acme".into())).unwrap();
assert_eq!(sa.as_deref(), Some("acme"));
let err = resolve_custody_context(&auth_scoped(&["acme"]), Some("other".into()));
assert!(matches!(err, Err(AppError::Forbidden(_))), "{err:?}");
}
#[test]
fn receive_body_parses_with_and_without_id() {
let with_id: ReceiveBody =
serde_json::from_value(json!({ "credential": {"id": "x"}, "id": "y" })).unwrap();
assert_eq!(with_id.id.as_deref(), Some("y"));
let without: ReceiveBody =
serde_json::from_value(json!({ "credential": {"id": "x"} })).unwrap();
assert_eq!(without.id, None);
}
}
#[cfg(test)]
mod receive_body_wire_tests {
use super::*;
use serde_json::json;
#[test]
fn a_pre_existing_body_still_deserializes_and_stays_on_the_di_path() {
let body: ReceiveBody = serde_json::from_value(json!({
"credential": {"id": "urn:uuid:abc", "type": ["VerifiableCredential"]},
"id": "urn:uuid:abc"
}))
.expect("an existing client body must still parse");
assert!(body.credential.is_some());
assert!(body.credential_base64.is_none());
assert!(
body.format.is_none(),
"absent format must stay absent — it is what selects the DI path"
);
}
#[test]
fn the_binary_field_is_camel_case_on_the_wire() {
let ok: ReceiveBody = serde_json::from_value(json!({
"credentialBase64": "AAAA",
"format": "mso_mdoc"
}))
.expect("camelCase parses");
assert_eq!(ok.credential_base64.as_deref(), Some("AAAA"));
let wrong: ReceiveBody = serde_json::from_value(json!({
"credential_base64": "AAAA",
"format": "mso_mdoc"
}))
.expect("unknown fields are ignored today");
assert!(
wrong.credential_base64.is_none(),
"snake_case must not bind — the wire contract is camelCase"
);
}
#[test]
fn the_mdoc_format_tag_matches_the_stored_credential_format() {
let body: ReceiveBody =
serde_json::from_value(json!({"credentialBase64": "AA", "format": "mso_mdoc"}))
.unwrap();
let stored = serde_json::to_string(&vta_vault::model::CredentialFormat::MsoMdoc).unwrap();
assert_eq!(
format!("\"{}\"", body.format.unwrap()),
stored,
"the wire format tag and the stored format tag must be the same token"
);
}
}
#[cfg(test)]
mod present_tests {
use super::*;
use crate::test_support::{
admin_claims_for_context, build_signing_test_app_state, seed_held_credential,
seed_holder_key, super_admin_claims,
};
use serde_json::json;
const AUDIENCE: &str = "did:example:community";
const NONCE: &str = "urn:uuid:7d1e0c4a-5b2f-4c3d-8e9f-00000000001b";
fn present_doc(id: &str, claims: &[&str]) -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = vta_sdk::trust_tasks::TASK_VAULT_CREDENTIALS_PRESENT_0_1
.parse()
.expect("present uri");
TrustTask::new(
"urn:uuid:test-present",
uri,
json!({ "id": id, "audience": AUDIENCE, "nonce": NONCE, "claims": claims }),
)
}
fn body(out: &TrustTaskOutcome) -> Value {
serde_json::from_slice(&out.body).expect("a JSON document")
}
fn code(out: &TrustTaskOutcome) -> String {
body(out)["payload"]["code"]
.as_str()
.unwrap_or_default()
.to_string()
}
async fn held_sd_jwt(state: &AppState) -> String {
let subject = seed_holder_key(state, "m/44'/0'/9'/0'/7'", None).await;
seed_held_credential(
&state.vault_ks,
"https://example.org/employment",
"employer",
&subject,
)
.await
}
#[tokio::test]
async fn an_sd_jwt_is_presented_to_the_named_audience_disclosing_only_the_named_claim() {
let (state, _dir) = build_signing_test_app_state().await;
let id = held_sd_jwt(&state).await;
let out = handle_present(
&state,
&super_admin_claims(),
present_doc(&id, &["employer"]),
)
.await;
let doc = body(&out);
assert_eq!(out.status, axum::http::StatusCode::OK, "{doc}");
assert_eq!(doc["payload"]["disclosed"], json!(["employer"]));
let compact = doc["payload"]["presentation"]
.as_str()
.expect("a compact SD-JWT");
let parsed =
affinidi_sd_jwt::SdJwt::parse(compact, &affinidi_sd_jwt::hasher::Sha256Hasher).unwrap();
assert_eq!(parsed.disclosures.len(), 1);
assert_eq!(
parsed.disclosures[0].claim_name.as_deref(),
Some("employer")
);
let kb = parsed.kb_jwt.as_ref().expect("a key-binding JWT");
let payload = kb.split('.').nth(1).expect("a JWT payload");
let payload: Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
assert_eq!(payload["aud"], AUDIENCE);
assert_eq!(payload["nonce"], NONCE);
}
#[tokio::test]
async fn a_claim_the_credential_does_not_carry_is_claim_not_held() {
let (state, _dir) = build_signing_test_app_state().await;
let id = held_sd_jwt(&state).await;
let out = handle_present(
&state,
&super_admin_claims(),
present_doc(&id, &["employer", "salary"]),
)
.await;
assert_eq!(code(&out), "vault/credentials/present:claimNotHeld");
assert_eq!(
body(&out)["payload"]["details"]["claims"],
json!(["salary"])
);
}
#[tokio::test]
async fn a_data_integrity_credential_is_refused_unless_every_claim_is_named() {
use crate::vault::model::{CredentialFormat, CredentialStatus, StoredCredential};
let (state, _dir) = build_signing_test_app_state().await;
let subject = seed_holder_key(&state, "m/44'/0'/9'/0'/8'", None).await;
let vc = json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "EmploymentCredential"],
"issuer": "did:web:issuer.example",
"credentialSubject": { "id": subject, "employer": "Acme", "salary": 1 },
});
let cred = StoredCredential {
id: "di-employment".into(),
format: CredentialFormat::EddsaJcs2022,
types: vec!["EmploymentCredential".into()],
schema_id: None,
community_did: None,
context_id: None,
subject_did: Some(subject.clone()),
issuer_did: Some("did:web:issuer.example".into()),
purpose: None,
status: CredentialStatus::Valid,
valid_from: None,
valid_until: None,
received_at: "2026-01-01T00:00:00Z".into(),
source: None,
tags: Default::default(),
body: serde_json::to_vec(&vc).unwrap(),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
};
storage::put(&state.vault_ks, &cred).await.unwrap();
let out = handle_present(
&state,
&super_admin_claims(),
present_doc("di-employment", &["employer"]),
)
.await;
assert_eq!(code(&out), "vault/credentials/present:wouldOverDisclose");
assert_eq!(
body(&out)["payload"]["details"]["undisclosed"],
json!(["salary"])
);
let out = handle_present(
&state,
&super_admin_claims(),
present_doc("di-employment", &["employer", "salary"]),
)
.await;
let doc = body(&out);
assert_eq!(out.status, axum::http::StatusCode::OK, "{doc}");
let vp = &doc["payload"]["presentation"];
assert_eq!(vp["holder"], subject);
assert_eq!(vp["domain"], AUDIENCE);
assert_eq!(vp["nonce"], NONCE);
assert!(vp.get("proof").is_some());
}
#[tokio::test]
async fn a_holder_key_outside_the_callers_scope_reads_as_not_found() {
let (state, _dir) = build_signing_test_app_state().await;
let id = held_sd_jwt(&state).await;
let scoped = admin_claims_for_context("elsewhere");
let out = handle_present(&state, &scoped, present_doc(&id, &["employer"])).await;
let unknown = handle_present(
&state,
&scoped,
present_doc("no-such-credential", &["employer"]),
)
.await;
assert_eq!(code(&out), "vault/credentials/present:notFound");
assert_eq!(code(&unknown), "vault/credentials/present:notFound");
assert_eq!(
body(&out)["payload"]["message"],
body(&unknown)["payload"]["message"]
);
}
async fn store_raw(
state: &AppState,
id: &str,
format: crate::vault::model::CredentialFormat,
valid_until: Option<&str>,
) {
use crate::vault::model::{CredentialStatus, StoredCredential};
let subject = seed_holder_key(state, "m/44'/0'/9'/0'/9'", None).await;
let vc = json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential"],
"issuer": "did:web:issuer.example",
"credentialSubject": { "id": subject, "employer": "Acme" },
});
let cred = StoredCredential {
id: id.into(),
format,
types: vec!["VerifiableCredential".into()],
schema_id: None,
community_did: None,
context_id: None,
subject_did: Some(subject),
issuer_did: Some("did:web:issuer.example".into()),
purpose: None,
status: CredentialStatus::Valid,
valid_from: None,
valid_until: valid_until.map(str::to_string),
received_at: "2026-01-01T00:00:00Z".into(),
source: None,
tags: Default::default(),
body: serde_json::to_vec(&vc).unwrap(),
lifecycle: VaultStatus::Active,
archived_at: None,
deleted_at: None,
grace_until: None,
};
storage::put(&state.vault_ks, &cred).await.unwrap();
}
#[tokio::test]
async fn an_expired_credential_is_not_presentable() {
use crate::vault::model::CredentialFormat;
let (state, _dir) = build_signing_test_app_state().await;
store_raw(
&state,
"expired",
CredentialFormat::EddsaJcs2022,
Some("2020-01-01T00:00:00Z"),
)
.await;
let out = handle_present(
&state,
&super_admin_claims(),
present_doc("expired", &["employer"]),
)
.await;
assert_eq!(code(&out), "vault/credentials/present:notPresentable");
}
#[tokio::test]
async fn an_mdoc_is_format_unsupported() {
use crate::vault::model::CredentialFormat;
let (state, _dir) = build_signing_test_app_state().await;
store_raw(&state, "an-mdoc", CredentialFormat::MsoMdoc, None).await;
let out = handle_present(
&state,
&super_admin_claims(),
present_doc("an-mdoc", &["employer"]),
)
.await;
assert_eq!(code(&out), "vault/credentials/present:formatUnsupported");
}
#[tokio::test]
async fn an_application_may_not_present() {
let (state, _dir) = build_signing_test_app_state().await;
let id = held_sd_jwt(&state).await;
let app = AuthClaims {
role: crate::acl::Role::Application,
..super_admin_claims()
};
let out = handle_present(&state, &app, present_doc(&id, &["employer"])).await;
assert_eq!(code(&out), "permissionDenied");
}
}