archy/core/archipelago/src/credentials.rs
Dorian 7139dc43a6 feat: Phase 8 — encrypt credentials at rest, DHT refresh, pkarr eval
- 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>
2026-03-15 04:59:20 +00:00

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());
}
}