bug fixing and deploy and build diagnostics
This commit is contained in:
12
core/archipelago/src/credentials/mod.rs
Normal file
12
core/archipelago/src/credentials/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! 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/
|
||||
|
||||
mod types;
|
||||
mod store;
|
||||
mod operations;
|
||||
mod presentation;
|
||||
|
||||
pub use store::load_credentials;
|
||||
pub use operations::{issue_credential, verify_credential, revoke_credential, list_credentials, is_revoked};
|
||||
pub use presentation::{VerifiablePresentation, create_presentation, verify_presentation};
|
||||
322
core/archipelago/src/credentials/operations.rs
Normal file
322
core/archipelago/src/credentials/operations.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
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<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> {
|
||||
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")
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
274
core/archipelago/src/credentials/presentation.rs
Normal file
274
core/archipelago/src/credentials/presentation.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::*;
|
||||
use super::operations::{verify_credential, is_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);
|
||||
|
||||
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> {
|
||||
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)?;
|
||||
|
||||
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::*;
|
||||
|
||||
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();
|
||||
|
||||
let result = verify_presentation(&vp, |did, _bytes, _sig| {
|
||||
Ok(did != "did:key:holder")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.holder_valid);
|
||||
assert!(!result.valid);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
106
core/archipelago/src/credentials/store.rs
Normal file
106
core/archipelago/src/credentials/store.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{CredentialStore, CREDENTIALS_DIR};
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
89
core/archipelago/src/credentials/types.rs
Normal file
89
core/archipelago/src/credentials/types.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(super) const CREDENTIALS_DIR: &str = "credentials";
|
||||
|
||||
/// W3C VC Data Model 2.0 context URI
|
||||
pub(super) const VC_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2";
|
||||
/// Ed25519 signature suite context
|
||||
pub(super) 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,
|
||||
}
|
||||
|
||||
/// Stored credentials index.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CredentialStore {
|
||||
pub credentials: Vec<VerifiableCredential>,
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user