//! Verifiable Credentials (VC) management following W3C VC Data Model 2.0. //! Implements JSON-LD @context, Ed25519Signature2020 proof format. //! See: https://www.w3.org/TR/vc-data-model-2.0/ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; use tracing::debug; const CREDENTIALS_DIR: &str = "credentials"; /// W3C VC Data Model 2.0 context URI const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2"; /// Ed25519 signature suite context const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1"; /// A Verifiable Credential following W3C VC Data Model 2.0. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VerifiableCredential { #[serde(rename = "@context")] pub context: Vec, pub id: String, #[serde(rename = "type")] pub credential_type: Vec, pub issuer: String, pub credential_subject: CredentialSubject, pub issuance_date: String, #[serde(skip_serializing_if = "Option::is_none")] pub expiration_date: Option, pub proof: CredentialProof, #[serde(skip_serializing_if = "Option::is_none")] pub credential_status: Option, } /// The subject of a credential with their DID and claims. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialSubject { pub id: String, #[serde(flatten)] pub claims: serde_json::Value, } /// Ed25519Signature2020 proof format. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CredentialProof { #[serde(rename = "type")] pub proof_type: String, pub created: String, pub verification_method: String, pub proof_purpose: String, pub proof_value: String, } /// Credential status for revocation tracking. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CredentialStatusEntry { pub id: String, #[serde(rename = "type")] pub status_type: String, pub status: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum CredentialStatus { Active, Revoked, Expired, } impl std::fmt::Display for CredentialStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CredentialStatus::Active => write!(f, "active"), CredentialStatus::Revoked => write!(f, "revoked"), CredentialStatus::Expired => write!(f, "expired"), } } } /// Stored credentials index. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CredentialStore { pub credentials: Vec, } async fn ensure_dir(data_dir: &Path) -> Result<()> { let dir = data_dir.join(CREDENTIALS_DIR); if !dir.exists() { fs::create_dir_all(&dir).await.context("Creating credentials dir")?; } Ok(()) } fn store_path(data_dir: &Path) -> std::path::PathBuf { data_dir.join(CREDENTIALS_DIR).join("credentials.json") } pub async fn load_credentials(data_dir: &Path) -> Result { ensure_dir(data_dir).await?; let path = store_path(data_dir); if !path.exists() { return Ok(CredentialStore::default()); } let data = fs::read_to_string(&path).await.context("Reading credentials")?; serde_json::from_str(&data).context("Parsing credentials") } pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> { ensure_dir(data_dir).await?; let path = store_path(data_dir); let data = serde_json::to_string_pretty(store)?; fs::write(&path, data).await.context("Writing 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 { // Reconstruct the body that was signed (without proof) 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") } /// A Verifiable Presentation following W3C VC Data Model 2.0. /// Bundles one or more VCs with a holder proof. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VerifiablePresentation { #[serde(rename = "@context")] pub context: Vec, pub id: String, #[serde(rename = "type")] pub presentation_type: Vec, pub holder: String, pub verifiable_credential: Vec, pub proof: CredentialProof, } /// Create a Verifiable Presentation wrapping selected credentials. /// The holder signs the presentation to prove they possess the credentials. pub fn create_presentation( holder_did: &str, credential_ids: &[&str], credentials: &[VerifiableCredential], sign_fn: impl FnOnce(&[u8]) -> Result, ) -> Result { let selected: Vec = credentials .iter() .filter(|c| credential_ids.contains(&c.id.as_str())) .cloned() .collect(); if selected.is_empty() { return Err(anyhow::anyhow!("No matching credentials found")); } let id = format!("urn:uuid:{}", uuid::Uuid::new_v4()); let created = chrono::Utc::now().to_rfc3339(); let key_id = format!("{}#key-1", holder_did); // Build the presentation body for signing (without proof) let body = serde_json::json!({ "@context": [VC_CONTEXT_V2, ED25519_CONTEXT], "id": id, "type": ["VerifiablePresentation"], "holder": holder_did, "verifiableCredential": selected, }); let body_bytes = serde_json::to_vec(&body)?; let signature = sign_fn(&body_bytes)?; Ok(VerifiablePresentation { context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()], id, presentation_type: vec!["VerifiablePresentation".to_string()], holder: holder_did.to_string(), verifiable_credential: selected, proof: CredentialProof { proof_type: "Ed25519Signature2020".to_string(), created, verification_method: key_id, proof_purpose: "authentication".to_string(), proof_value: signature, }, }) } /// Verify a Verifiable Presentation: check holder's proof signature, /// then verify each embedded credential. pub fn verify_presentation( vp: &VerifiablePresentation, verify_fn: impl Fn(&str, &[u8], &str) -> Result, ) -> Result { // 1. Verify the holder's presentation proof let body = serde_json::json!({ "@context": vp.context, "id": vp.id, "type": vp.presentation_type, "holder": vp.holder, "verifiableCredential": vp.verifiable_credential, }); let body_bytes = serde_json::to_vec(&body)?; let holder_valid = verify_fn(&vp.holder, &body_bytes, &vp.proof.proof_value)?; // 2. Verify each embedded credential let mut credential_results = Vec::new(); for vc in &vp.verifiable_credential { let vc_valid = verify_credential(vc, |did, bytes, sig| verify_fn(did, bytes, sig))?; credential_results.push(CredentialVerificationResult { id: vc.id.clone(), valid: vc_valid, revoked: is_revoked(vc), }); } let all_valid = holder_valid && credential_results.iter().all(|r| r.valid && !r.revoked); Ok(PresentationVerification { holder_valid, credentials: credential_results, valid: all_valid, }) } /// Result of verifying a Verifiable Presentation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PresentationVerification { pub holder_valid: bool, pub credentials: Vec, pub valid: bool, } /// Result of verifying a single credential within a presentation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialVerificationResult { pub id: String, pub valid: bool, pub revoked: bool, } #[cfg(test)] mod tests { use super::*; #[test] fn test_credential_status_display() { assert_eq!(CredentialStatus::Active.to_string(), "active"); assert_eq!(CredentialStatus::Revoked.to_string(), "revoked"); assert_eq!(CredentialStatus::Expired.to_string(), "expired"); } #[test] fn test_credential_status_serde_roundtrip() { let json = serde_json::to_string(&CredentialStatus::Revoked).unwrap(); assert_eq!(json, "\"revoked\""); let parsed: CredentialStatus = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, CredentialStatus::Revoked); } #[test] fn test_credential_store_default_is_empty() { let store = CredentialStore::default(); assert!(store.credentials.is_empty()); } #[tokio::test] async fn test_load_credentials_returns_empty_when_no_file() { let dir = tempfile::tempdir().unwrap(); let store = load_credentials(dir.path()).await.unwrap(); assert!(store.credentials.is_empty()); assert!(dir.path().join(CREDENTIALS_DIR).exists()); } #[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(); // W3C structure checks 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(); // Must have @context assert!(json["@context"].is_array()); // Must have type array assert!(json["type"].is_array()); // Must have credentialSubject assert!(json["credentialSubject"]["id"].is_string()); // Must have proof 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); } fn make_test_vc(id: &str, issuer: &str, subject: &str) -> VerifiableCredential { VerifiableCredential { context: vec![VC_CONTEXT_V2.to_string(), ED25519_CONTEXT.to_string()], id: id.to_string(), credential_type: vec!["VerifiableCredential".to_string(), "Test".to_string()], issuer: issuer.to_string(), credential_subject: CredentialSubject { id: subject.to_string(), claims: serde_json::json!({"role": "tester"}), }, issuance_date: "2026-01-01T00:00:00Z".to_string(), expiration_date: None, proof: CredentialProof { proof_type: "Ed25519Signature2020".to_string(), created: "2026-01-01T00:00:00Z".to_string(), verification_method: format!("{}#key-1", issuer), proof_purpose: "assertionMethod".to_string(), proof_value: "mock-sig".to_string(), }, credential_status: None, } } #[test] fn test_create_presentation() { let creds = vec![ make_test_vc("urn:uuid:cred1", "did:key:issuer1", "did:key:holder"), make_test_vc("urn:uuid:cred2", "did:key:issuer2", "did:key:holder"), ]; let vp = create_presentation( "did:key:holder", &["urn:uuid:cred1"], &creds, |_bytes| Ok("presentation-sig".to_string()), ) .unwrap(); assert!(vp.id.starts_with("urn:uuid:")); assert_eq!(vp.presentation_type, vec!["VerifiablePresentation"]); assert_eq!(vp.holder, "did:key:holder"); assert_eq!(vp.verifiable_credential.len(), 1); assert_eq!(vp.verifiable_credential[0].id, "urn:uuid:cred1"); assert_eq!(vp.proof.proof_type, "Ed25519Signature2020"); assert_eq!(vp.proof.proof_purpose, "authentication"); assert_eq!(vp.proof.proof_value, "presentation-sig"); assert_eq!(vp.context[0], VC_CONTEXT_V2); } #[test] fn test_create_presentation_multiple_credentials() { let creds = vec![ make_test_vc("urn:uuid:c1", "did:key:i1", "did:key:holder"), make_test_vc("urn:uuid:c2", "did:key:i2", "did:key:holder"), make_test_vc("urn:uuid:c3", "did:key:i3", "did:key:other"), ]; let vp = create_presentation( "did:key:holder", &["urn:uuid:c1", "urn:uuid:c2"], &creds, |_| Ok("sig".to_string()), ) .unwrap(); assert_eq!(vp.verifiable_credential.len(), 2); } #[test] fn test_create_presentation_no_matching_credentials() { let creds = vec![make_test_vc("urn:uuid:c1", "did:key:i", "did:key:h")]; let result = create_presentation( "did:key:holder", &["urn:uuid:nonexistent"], &creds, |_| Ok("sig".to_string()), ); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("No matching credentials")); } #[test] fn test_verify_presentation_all_valid() { let creds = vec![ make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"), ]; let vp = create_presentation( "did:key:holder", &["urn:uuid:c1"], &creds, |_| Ok("vp-sig".to_string()), ) .unwrap(); let result = verify_presentation(&vp, |_did, _bytes, _sig| Ok(true)).unwrap(); assert!(result.holder_valid); assert!(result.valid); assert_eq!(result.credentials.len(), 1); assert!(result.credentials[0].valid); assert!(!result.credentials[0].revoked); } #[test] fn test_verify_presentation_holder_invalid() { let creds = vec![ make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"), ]; let vp = create_presentation( "did:key:holder", &["urn:uuid:c1"], &creds, |_| Ok("bad-sig".to_string()), ) .unwrap(); // Holder verification fails, credential verification succeeds let result = verify_presentation(&vp, |did, _bytes, _sig| { Ok(did != "did:key:holder") }) .unwrap(); assert!(!result.holder_valid); assert!(!result.valid); // Overall invalid because holder proof failed } #[test] fn test_presentation_serializes_as_jsonld() { let creds = vec![ make_test_vc("urn:uuid:c1", "did:key:issuer", "did:key:holder"), ]; let vp = create_presentation( "did:key:holder", &["urn:uuid:c1"], &creds, |_| Ok("sig".to_string()), ) .unwrap(); let json = serde_json::to_value(&vp).unwrap(); assert!(json["@context"].is_array()); assert!(json["type"].is_array()); assert_eq!(json["type"][0], "VerifiablePresentation"); assert!(json["holder"].is_string()); assert!(json["verifiableCredential"].is_array()); assert!(json["proof"]["type"].is_string()); } }