use affinidi_sd_jwt::hasher::Sha256Hasher;
use affinidi_sd_jwt::signer::JwtSigner;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::{Map, Value, json};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::model::{CredentialPurpose, StoredCredential};
use super::receive;
#[derive(Debug, Clone)]
pub struct MintRequest<'a> {
pub vct: &'a str,
pub issuer_did: &'a str,
pub subject_did: &'a str,
pub claims: &'a Value,
pub disclosable: &'a [&'a str],
pub iat: u64,
pub exp: Option<u64>,
}
pub fn mint_sd_jwt_vc(req: &MintRequest<'_>, signer: &dyn JwtSigner) -> Result<String, AppError> {
if req.vct.trim().is_empty() {
return Err(AppError::Validation("vct must be non-empty".to_string()));
}
if req.issuer_did.trim().is_empty() {
return Err(AppError::Validation(
"issuer_did must be non-empty".to_string(),
));
}
if req.subject_did.trim().is_empty() {
return Err(AppError::Validation(
"subject_did must be non-empty".to_string(),
));
}
let claims_obj = req
.claims
.as_object()
.ok_or_else(|| AppError::Validation("claims must be a JSON object".to_string()))?;
const PROTECTED: &[&str] = &["vct", "iss", "iat", "exp", "nbf", "cnf", "sub", "status"];
for name in req.disclosable {
if PROTECTED.contains(name) {
return Err(AppError::Validation(format!(
"`{name}` is a protected claim and cannot be selectively disclosable"
)));
}
if !claims_obj.contains_key(*name) {
return Err(AppError::Validation(format!(
"disclosable claim `{name}` is not present in `claims`"
)));
}
}
let holder_jwk = ed25519_did_key_to_cnf_jwk(req.subject_did)?;
let disclosure_frame = json!({
"_sd": req.disclosable.iter().map(|s| Value::String((*s).to_string())).collect::<Vec<_>>(),
});
let hasher = Sha256Hasher;
let vc = affinidi_sd_jwt_vc::issue(
req.vct,
req.issuer_did,
Some(req.subject_did),
req.claims,
&disclosure_frame,
signer,
&hasher,
Some(&holder_jwk),
req.iat,
req.exp,
)
.map_err(|e| AppError::Validation(format!("SD-JWT-VC issue failed: {e}")))?;
Ok(vc.serialize())
}
pub async fn mint_and_store_sd_jwt_vc(
vault: &KeyspaceHandle,
id: &str,
req: &MintRequest<'_>,
signer: &dyn JwtSigner,
now_unix: u64,
) -> Result<StoredCredential, AppError> {
let compact = mint_sd_jwt_vc(req, signer)?;
receive::receive_sd_jwt_vc(
vault,
id,
&compact,
Some("self-minted".to_string()),
now_unix,
)
.await
}
fn ed25519_did_key_to_cnf_jwk(subject_did: &str) -> Result<Value, AppError> {
let pub_bytes = affinidi_crypto::did_key::did_key_to_ed25519_pub(subject_did).map_err(|e| {
AppError::Validation(format!(
"subject_did ({subject_did}) is not a resolvable Ed25519 did:key: {e}"
))
})?;
let x = URL_SAFE_NO_PAD.encode(pub_bytes);
let mut jwk = Map::new();
jwk.insert("kty".to_string(), Value::String("OKP".to_string()));
jwk.insert("crv".to_string(), Value::String("Ed25519".to_string()));
jwk.insert("x".to_string(), Value::String(x));
Ok(Value::Object(jwk))
}
#[allow(dead_code)]
fn purpose_for_vct(vct: &str) -> Option<CredentialPurpose> {
let lower = vct.to_ascii_lowercase();
if lower.contains("invitation") || lower.contains("invite") {
Some(CredentialPurpose::Invite)
} else if lower.contains("membership") {
Some(CredentialPurpose::Membership)
} else if lower.contains("role") {
Some(CredentialPurpose::Role)
} else if lower.contains("endorsement") {
Some(CredentialPurpose::Endorsement)
} else if lower.contains("personhood") {
Some(CredentialPurpose::Personhood)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::super::model::{CredentialFormat, CredentialStatus};
use super::super::storage;
use super::*;
use affinidi_sd_jwt::SdJwt;
use affinidi_sd_jwt::error::SdJwtError;
use affinidi_sd_jwt::signer::JwtSigner;
use affinidi_sd_jwt::verifier::{VerificationOptions, verify};
use ed25519_dalek::{Signature, Signer, SigningKey};
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use vti_common::config::StoreConfig;
use vti_common::store::Store;
struct EddsaSigner {
key: SigningKey,
kid: String,
used: AtomicBool,
}
impl JwtSigner for EddsaSigner {
fn algorithm(&self) -> &str {
"EdDSA"
}
fn key_id(&self) -> Option<&str> {
Some(&self.kid)
}
fn sign_jwt(&self, header: &Value, payload: &Value) -> Result<String, SdJwtError> {
self.used.store(true, Ordering::SeqCst);
let header_b64 = URL_SAFE_NO_PAD.encode(
serde_json::to_string(header)
.map_err(SdJwtError::from)?
.as_bytes(),
);
let payload_b64 = URL_SAFE_NO_PAD.encode(
serde_json::to_string(payload)
.map_err(SdJwtError::from)?
.as_bytes(),
);
let signing_input = format!("{header_b64}.{payload_b64}");
let sig: Signature = self.key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
Ok(format!("{signing_input}.{sig_b64}"))
}
}
fn issuer(seed: u8) -> (EddsaSigner, String) {
let signing = SigningKey::from_bytes(&[seed; 32]);
let did =
affinidi_crypto::did_key::ed25519_pub_to_did_key(signing.verifying_key().as_bytes());
let kid = format!("{did}#key-0");
(
EddsaSigner {
key: signing,
kid,
used: AtomicBool::new(false),
},
did,
)
}
fn holder_did(seed: u8) -> String {
let signing = SigningKey::from_bytes(&[seed; 32]);
affinidi_crypto::did_key::ed25519_pub_to_did_key(signing.verifying_key().as_bytes())
}
struct IssuerVerifier {
key: ed25519_dalek::VerifyingKey,
}
impl affinidi_sd_jwt::signer::JwtVerifier for IssuerVerifier {
fn verify_jwt(&self, jws: &str) -> Result<Value, SdJwtError> {
use ed25519_dalek::Verifier;
let parts: Vec<&str> = jws.split('.').collect();
if parts.len() != 3 {
return Err(SdJwtError::Verification("malformed JWS".into()));
}
let signing_input = format!("{}.{}", parts[0], parts[1]);
let sig_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|e| SdJwtError::Verification(e.to_string()))?;
let sig = Signature::from_slice(&sig_bytes)
.map_err(|e| SdJwtError::Verification(e.to_string()))?;
self.key
.verify(signing_input.as_bytes(), &sig)
.map_err(|_| SdJwtError::Verification("bad sig".into()))?;
let payload = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| SdJwtError::Verification(e.to_string()))?;
serde_json::from_slice(&payload).map_err(|e| SdJwtError::Verification(e.to_string()))
}
}
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)
}
#[test]
fn minted_vc_verifies_carries_vct_cnf_and_hides_disclosable_claims() {
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({
"community": "did:web:community.example",
"tier": "founding",
"public_label": "VTC East",
});
let req = MintRequest {
vct: "https://openvtc.org/credentials/MembershipCredential",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &["community", "tier"],
iat: 1_700_000_000,
exp: Some(1_900_000_000),
};
let compact = mint_sd_jwt_vc(&req, &signer).expect("mint");
assert!(signer.used.load(Ordering::SeqCst));
let hasher = Sha256Hasher;
let sd_jwt = SdJwt::parse(&compact, &hasher).expect("parse");
let body = sd_jwt.payload().expect("payload");
assert_eq!(
body["vct"],
"https://openvtc.org/credentials/MembershipCredential"
);
assert_eq!(body["iss"], issuer_did);
assert_eq!(body["sub"], subject);
let expected_x = URL_SAFE_NO_PAD
.encode(affinidi_crypto::did_key::did_key_to_ed25519_pub(&subject).unwrap());
assert_eq!(body["cnf"]["jwk"]["kty"], "OKP");
assert_eq!(body["cnf"]["jwk"]["crv"], "Ed25519");
assert_eq!(body["cnf"]["jwk"]["x"], expected_x);
assert_eq!(body["public_label"], "VTC East");
assert!(body.get("community").is_none());
assert!(body.get("tier").is_none());
let sd = body.get("_sd").and_then(Value::as_array).expect("_sd");
assert_eq!(sd.len(), 2, "two disclosure digests in the signed body");
let body_str = serde_json::to_string(&body).unwrap();
assert!(!body_str.contains("founding"));
assert!(!body_str.contains("did:web:community.example"));
assert_eq!(sd_jwt.disclosures.len(), 2);
let mut recovered: Vec<(String, Value)> = sd_jwt
.disclosures
.iter()
.map(|d| {
(
d.claim_name.clone().unwrap_or_default(),
d.claim_value.clone(),
)
})
.collect();
recovered.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(recovered[0].0, "community");
assert_eq!(recovered[0].1, "did:web:community.example");
assert_eq!(recovered[1].0, "tier");
assert_eq!(recovered[1].1, "founding");
let pub_bytes = affinidi_crypto::did_key::did_key_to_ed25519_pub(&issuer_did).unwrap();
let verifier = IssuerVerifier {
key: ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes).unwrap(),
};
let opts = VerificationOptions::default();
let result = verify(&sd_jwt, &verifier, &hasher, &opts, None).expect("verify");
assert!(result.is_verified());
assert_eq!(result.claims["community"], "did:web:community.example");
assert_eq!(result.claims["tier"], "founding");
affinidi_sd_jwt_vc::verify_temporal(&result.claims, 1_800_000_000).expect("temporal");
}
#[test]
fn minted_vc_signature_is_bound_to_the_issuer_key() {
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({ "x": "y" });
let req = MintRequest {
vct: "RoleCredential",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &["x"],
iat: 1_700_000_000,
exp: None,
};
let compact = mint_sd_jwt_vc(&req, &signer).expect("mint");
let hasher = Sha256Hasher;
let sd_jwt = SdJwt::parse(&compact, &hasher).unwrap();
let (_other, other_did) = issuer(3);
let wrong_pub = affinidi_crypto::did_key::did_key_to_ed25519_pub(&other_did).unwrap();
let verifier = IssuerVerifier {
key: ed25519_dalek::VerifyingKey::from_bytes(&wrong_pub).unwrap(),
};
let opts = VerificationOptions::default();
assert!(verify(&sd_jwt, &verifier, &hasher, &opts, None).is_err());
}
#[test]
fn empty_disclosable_mints_a_fully_cleartext_vc() {
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({ "a": 1, "b": 2 });
let req = MintRequest {
vct: "EndorsementCredential",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &[],
iat: 1_700_000_000,
exp: None,
};
let compact = mint_sd_jwt_vc(&req, &signer).expect("mint");
let hasher = Sha256Hasher;
let sd_jwt = SdJwt::parse(&compact, &hasher).unwrap();
assert_eq!(sd_jwt.disclosures.len(), 0);
let body = sd_jwt.payload().unwrap();
assert_eq!(body["a"], 1);
assert_eq!(body["b"], 2);
}
#[test]
fn rejects_disclosable_claim_not_in_claims() {
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({ "a": 1 });
let req = MintRequest {
vct: "X",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &["nope"],
iat: 1_700_000_000,
exp: None,
};
let err = mint_sd_jwt_vc(&req, &signer).expect_err("must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn rejects_disclosing_protected_claim() {
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({ "a": 1 });
let req = MintRequest {
vct: "X",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &["iss"],
iat: 1_700_000_000,
exp: None,
};
let err = mint_sd_jwt_vc(&req, &signer).expect_err("must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn rejects_bad_subject_did() {
let (signer, issuer_did) = issuer(9);
let claims = json!({ "a": 1 });
let req = MintRequest {
vct: "X",
issuer_did: &issuer_did,
subject_did: "did:web:not-a-key",
claims: &claims,
disclosable: &["a"],
iat: 1_700_000_000,
exp: None,
};
let err = mint_sd_jwt_vc(&req, &signer).expect_err("must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn rejects_empty_vct() {
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({ "a": 1 });
let req = MintRequest {
vct: " ",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &["a"],
iat: 1_700_000_000,
exp: None,
};
let err = mint_sd_jwt_vc(&req, &signer).expect_err("must reject");
assert!(matches!(err, AppError::Validation(_)));
}
#[tokio::test]
async fn mint_and_store_files_it_into_the_vault_indexed() {
let (_dir, _store, vault) = fresh_vault();
let (signer, issuer_did) = issuer(9);
let subject = holder_did(7);
let claims = json!({ "community": "did:web:c.example", "tier": "gold" });
let req = MintRequest {
vct: "https://openvtc.org/credentials/MembershipCredential",
issuer_did: &issuer_did,
subject_did: &subject,
claims: &claims,
disclosable: &["tier"],
iat: 1_700_000_000,
exp: Some(1_900_000_000),
};
let stored = mint_and_store_sd_jwt_vc(&vault, "minted-1", &req, &signer, 1_800_000_000)
.await
.expect("mint+store");
assert_eq!(stored.id, "minted-1");
assert_eq!(stored.format, CredentialFormat::SdJwtVc);
assert_eq!(stored.issuer_did.as_deref(), Some(issuer_did.as_str()));
assert_eq!(stored.subject_did.as_deref(), Some(subject.as_str()));
assert_eq!(stored.status, CredentialStatus::Valid);
assert_eq!(stored.purpose, Some(CredentialPurpose::Membership));
assert_eq!(stored.source.as_deref(), Some("self-minted"));
let got = storage::get(&vault, "minted-1").await.unwrap().unwrap();
assert_eq!(got.body, stored.body);
let by_type = storage::find_by_index(
&vault,
crate::vault::IndexField::Type,
"https://openvtc.org/credentials/MembershipCredential",
)
.await
.unwrap();
assert_eq!(by_type.len(), 1);
assert_eq!(by_type[0].id, "minted-1");
}
#[test]
fn purpose_for_vct_maps_catalog() {
assert_eq!(
purpose_for_vct("InvitationCredential"),
Some(CredentialPurpose::Invite)
);
assert_eq!(
purpose_for_vct("x/RoleCredential"),
Some(CredentialPurpose::Role)
);
assert_eq!(purpose_for_vct("Mystery"), None);
}
}