use anyhow::Result; use std::path::Path; use tracing::debug; use super::types::*; use super::store::{load_credentials, save_credentials}; /// Issue a new Verifiable Credential following W3C VC Data Model 2.0. /// Uses Ed25519Signature2020 proof format. pub async fn issue_credential( data_dir: &Path, issuer_did: &str, subject_did: &str, credential_type: &str, claims: serde_json::Value, expires_at: Option<&str>, sign_fn: impl FnOnce(&[u8]) -> Result, ) -> Result { let id = format!("urn:uuid:{}", uuid::Uuid::new_v4()); let issued_at = chrono::Utc::now().to_rfc3339(); let key_id = format!("{}#key-1", issuer_did); // Build the credential body for signing (without proof) let body = serde_json::json!({ "@context": [VC_CONTEXT_V2, ED25519_CONTEXT], "id": id, "type": ["VerifiableCredential", credential_type], "issuer": issuer_did, "credentialSubject": { "id": subject_did, }, "issuanceDate": issued_at, }); let body_bytes = serde_json::to_vec(&body)?; let signature = sign_fn(&body_bytes)?; let vc = VerifiableCredential { context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()], id: id.clone(), credential_type: vec!["VerifiableCredential".to_string(), credential_type.to_string()], issuer: issuer_did.to_string(), credential_subject: CredentialSubject { id: subject_did.to_string(), claims, }, issuance_date: issued_at.clone(), expiration_date: expires_at.map(|s| s.to_string()), proof: CredentialProof { proof_type: "Ed25519Signature2020".to_string(), created: issued_at, verification_method: key_id, proof_purpose: "assertionMethod".to_string(), proof_value: signature, }, credential_status: None, }; let mut store = load_credentials(data_dir).await?; debug!(id = %vc.id, "Issued W3C VC"); store.credentials.push(vc.clone()); save_credentials(data_dir, &store).await?; Ok(vc) } /// Verify a credential's signature against the issuer DID. pub fn verify_credential( vc: &VerifiableCredential, verify_fn: impl FnOnce(&str, &[u8], &str) -> Result, ) -> Result { let body = serde_json::json!({ "@context": vc.context, "id": vc.id, "type": vc.credential_type, "issuer": vc.issuer, "credentialSubject": { "id": vc.credential_subject.id, }, "issuanceDate": vc.issuance_date, }); let body_bytes = serde_json::to_vec(&body)?; verify_fn(&vc.issuer, &body_bytes, &vc.proof.proof_value) } /// Revoke a credential by ID. pub async fn revoke_credential(data_dir: &Path, credential_id: &str) -> Result<()> { let mut store = load_credentials(data_dir).await?; let vc = store .credentials .iter_mut() .find(|c| c.id == credential_id) .ok_or_else(|| anyhow::anyhow!("Credential not found: {}", credential_id))?; vc.credential_status = Some(CredentialStatusEntry { id: format!("{}#status", credential_id), status_type: "CredentialStatusList2021".to_string(), status: "revoked".to_string(), }); save_credentials(data_dir, &store).await } /// List all credentials, optionally filtering by issuer or subject DID. pub async fn list_credentials( data_dir: &Path, filter_did: Option<&str>, ) -> Result> { let store = load_credentials(data_dir).await?; let creds = if let Some(did) = filter_did { store .credentials .into_iter() .filter(|c| c.issuer == did || c.credential_subject.id == did) .collect() } else { store.credentials }; Ok(creds) } /// Check if a credential is revoked. pub fn is_revoked(vc: &VerifiableCredential) -> bool { vc.credential_status .as_ref() .map_or(false, |s| s.status == "revoked") } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_issue_credential_w3c_format() { let dir = tempfile::tempdir().unwrap(); let vc = issue_credential( dir.path(), "did:key:issuer", "did:key:subject", "NodeOperator", serde_json::json!({"role": "admin"}), Some("2027-12-31T23:59:59Z"), |_bytes| Ok("mock-signature".to_string()), ) .await .unwrap(); assert!(vc.id.starts_with("urn:uuid:")); assert_eq!(vc.context[0], VC_CONTEXT_V2); assert_eq!(vc.context[1], ED25519_CONTEXT); assert_eq!(vc.credential_type, vec!["VerifiableCredential", "NodeOperator"]); assert_eq!(vc.issuer, "did:key:issuer"); assert_eq!(vc.credential_subject.id, "did:key:subject"); assert_eq!(vc.proof.proof_type, "Ed25519Signature2020"); assert_eq!(vc.proof.proof_purpose, "assertionMethod"); assert_eq!(vc.proof.verification_method, "did:key:issuer#key-1"); assert_eq!(vc.proof.proof_value, "mock-signature"); assert_eq!(vc.expiration_date, Some("2027-12-31T23:59:59Z".to_string())); assert!(vc.credential_status.is_none()); } #[tokio::test] async fn test_issue_credential_serializes_as_jsonld() { let dir = tempfile::tempdir().unwrap(); let vc = issue_credential( dir.path(), "did:key:issuer", "did:key:subject", "TestCred", serde_json::json!({"level": "gold"}), None, |_| Ok("sig".to_string()), ) .await .unwrap(); let json = serde_json::to_value(&vc).unwrap(); assert!(json["@context"].is_array()); assert!(json["type"].is_array()); assert!(json["credentialSubject"]["id"].is_string()); assert_eq!(json["proof"]["type"], "Ed25519Signature2020"); } #[tokio::test] async fn test_save_and_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); issue_credential( dir.path(), "did:key:a", "did:key:b", "Type1", serde_json::json!({"k": "v"}), None, |_| Ok("s1".to_string()), ) .await .unwrap(); let loaded = load_credentials(dir.path()).await.unwrap(); assert_eq!(loaded.credentials.len(), 1); assert_eq!(loaded.credentials[0].credential_type[1], "Type1"); } #[tokio::test] async fn test_issue_credential_sign_fn_failure_propagates() { let dir = tempfile::tempdir().unwrap(); let result = issue_credential( dir.path(), "did:key:issuer", "did:key:subject", "TestCredential", serde_json::json!({}), None, |_bytes| Err(anyhow::anyhow!("Signing failed")), ) .await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Signing failed")); } #[test] fn test_verify_credential_calls_verify_fn() { let vc = VerifiableCredential { context: vec![VC_CONTEXT_V2.to_string()], id: "urn:uuid:test".to_string(), credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()], issuer: "did:key:issuer".to_string(), credential_subject: CredentialSubject { id: "did:key:subject".to_string(), claims: serde_json::json!({"foo": "bar"}), }, issuance_date: "2025-06-01T00:00:00Z".to_string(), expiration_date: None, proof: CredentialProof { proof_type: "Ed25519Signature2020".to_string(), created: "2025-06-01T00:00:00Z".to_string(), verification_method: "did:key:issuer#key-1".to_string(), proof_purpose: "assertionMethod".to_string(), proof_value: "valid-sig".to_string(), }, credential_status: None, }; let result = verify_credential(&vc, |issuer, _data, sig| { assert_eq!(issuer, "did:key:issuer"); assert_eq!(sig, "valid-sig"); Ok(true) }) .unwrap(); assert!(result); let result = verify_credential(&vc, |_issuer, _data, _sig| Ok(false)).unwrap(); assert!(!result); } #[tokio::test] async fn test_revoke_credential() { let dir = tempfile::tempdir().unwrap(); let vc = issue_credential( dir.path(), "did:key:issuer", "did:key:subject", "Revocable", serde_json::json!({}), None, |_| Ok("sig".to_string()), ) .await .unwrap(); assert!(!is_revoked(&vc)); revoke_credential(dir.path(), &vc.id).await.unwrap(); let store = load_credentials(dir.path()).await.unwrap(); assert!(is_revoked(&store.credentials[0])); assert_eq!( store.credentials[0].credential_status.as_ref().unwrap().status, "revoked" ); } #[tokio::test] async fn test_revoke_nonexistent_credential_fails() { let dir = tempfile::tempdir().unwrap(); let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Credential not found")); } #[tokio::test] async fn test_list_credentials_no_filter() { let dir = tempfile::tempdir().unwrap(); issue_credential( dir.path(), "did:key:a", "did:key:b", "Type1", serde_json::json!({}), None, |_| Ok("s1".to_string()), ).await.unwrap(); issue_credential( dir.path(), "did:key:c", "did:key:d", "Type2", serde_json::json!({}), None, |_| Ok("s2".to_string()), ).await.unwrap(); let all = list_credentials(dir.path(), None).await.unwrap(); assert_eq!(all.len(), 2); } #[tokio::test] async fn test_list_credentials_filter_by_did() { let dir = tempfile::tempdir().unwrap(); issue_credential( dir.path(), "did:key:alice", "did:key:bob", "Type1", serde_json::json!({}), None, |_| Ok("s1".to_string()), ).await.unwrap(); issue_credential( dir.path(), "did:key:carol", "did:key:alice", "Type2", serde_json::json!({}), None, |_| Ok("s2".to_string()), ).await.unwrap(); issue_credential( dir.path(), "did:key:carol", "did:key:dave", "Type3", serde_json::json!({}), None, |_| Ok("s3".to_string()), ).await.unwrap(); let filtered = list_credentials(dir.path(), Some("did:key:alice")).await.unwrap(); assert_eq!(filtered.len(), 2); } }