- Credentials now encrypted with ChaCha20-Poly1305 using node key - Auto-detects plaintext JSON for migration from existing installs - Added did:dht auto-refresh background task (every 2 hours) - Documented pkarr evaluation findings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
797 lines
27 KiB
Rust
797 lines
27 KiB
Rust
//! 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<String>,
|
|
pub id: String,
|
|
#[serde(rename = "type")]
|
|
pub credential_type: Vec<String>,
|
|
pub issuer: String,
|
|
pub credential_subject: CredentialSubject,
|
|
pub issuance_date: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expiration_date: Option<String>,
|
|
pub proof: CredentialProof,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub credential_status: Option<CredentialStatusEntry>,
|
|
}
|
|
|
|
/// 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<VerifiableCredential>,
|
|
}
|
|
|
|
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<CredentialStore> {
|
|
ensure_dir(data_dir).await?;
|
|
let path = store_path(data_dir);
|
|
if !path.exists() {
|
|
return Ok(CredentialStore::default());
|
|
}
|
|
let raw = fs::read(&path).await.context("Reading credentials")?;
|
|
// Detect plaintext JSON (migration path) vs encrypted binary
|
|
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
|
|
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
|
return serde_json::from_str(&data).context("Parsing credentials");
|
|
}
|
|
// Encrypted: decrypt using node key
|
|
let key = load_encryption_key(data_dir).await?;
|
|
let plaintext = decrypt_credentials(&raw, &key)?;
|
|
serde_json::from_slice(&plaintext).context("Parsing decrypted 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_vec(store)?;
|
|
// Encrypt using node key
|
|
let key = load_encryption_key(data_dir).await?;
|
|
let encrypted = encrypt_credentials(&data, &key)?;
|
|
fs::write(&path, encrypted).await.context("Writing credentials")
|
|
}
|
|
|
|
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
|
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
|
let node_key_path = data_dir.join("identity").join("node_key");
|
|
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
|
|
use sha2::{Sha256, Digest};
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(b"archipelago-credential-store-v1");
|
|
hasher.update(&key_bytes);
|
|
let hash = hasher.finalize();
|
|
let mut key = [0u8; 32];
|
|
key.copy_from_slice(&hash);
|
|
Ok(key)
|
|
}
|
|
|
|
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
let nonce_bytes: [u8; 12] = rand::random();
|
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
|
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
|
let ciphertext = cipher
|
|
.encrypt(
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
|
data,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
|
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
|
output.extend_from_slice(&nonce_bytes);
|
|
output.extend_from_slice(&ciphertext);
|
|
Ok(output)
|
|
}
|
|
|
|
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
if data.len() < 12 {
|
|
anyhow::bail!("Encrypted credentials too short");
|
|
}
|
|
let nonce = &data[..12];
|
|
let ciphertext = &data[12..];
|
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
|
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
|
cipher
|
|
.decrypt(
|
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
|
ciphertext,
|
|
)
|
|
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
|
|
}
|
|
|
|
/// 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<String>,
|
|
) -> Result<VerifiableCredential> {
|
|
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<bool>,
|
|
) -> Result<bool> {
|
|
// 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<Vec<VerifiableCredential>> {
|
|
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<String>,
|
|
pub id: String,
|
|
#[serde(rename = "type")]
|
|
pub presentation_type: Vec<String>,
|
|
pub holder: String,
|
|
pub verifiable_credential: Vec<VerifiableCredential>,
|
|
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<String>,
|
|
) -> Result<VerifiablePresentation> {
|
|
let selected: Vec<VerifiableCredential> = 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<bool>,
|
|
) -> Result<PresentationVerification> {
|
|
// 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<CredentialVerificationResult>,
|
|
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());
|
|
}
|
|
}
|