Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
//! 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 operations;
mod presentation;
mod store;
mod types;
pub use operations::{
is_revoked, issue_credential, list_credentials, revoke_credential, verify_credential,
};
pub use presentation::{create_presentation, verify_presentation, VerifiablePresentation};
pub use store::load_credentials;
@@ -0,0 +1,384 @@
use anyhow::Result;
use std::path::Path;
use tracing::debug;
use super::store::{load_credentials, save_credentials};
use super::types::*;
/// 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()
.is_some_and(|s| s.status == "revoked")
}
#[cfg(test)]
mod tests {
use super::*;
/// Create a tempdir with a dummy `identity/node_key` so that the
/// credential store's encrypt/decrypt path can derive a key.
/// Returns the tempdir guard (drop it to clean up).
fn test_dir_with_node_key() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let identity_dir = dir.path().join("identity");
std::fs::create_dir_all(&identity_dir).unwrap();
// 32 bytes of deterministic test material; never a real key.
std::fs::write(identity_dir.join("node_key"), [0xAB; 32]).unwrap();
dir
}
#[tokio::test]
async fn test_issue_credential_w3c_format() {
let dir = test_dir_with_node_key();
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 = test_dir_with_node_key();
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 = test_dir_with_node_key();
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 = test_dir_with_node_key();
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 = test_dir_with_node_key();
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 = test_dir_with_node_key();
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 = test_dir_with_node_key();
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 = test_dir_with_node_key();
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);
}
}
@@ -0,0 +1,267 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use super::operations::{is_revoked, verify_credential};
use super::types::*;
/// 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());
}
}
+417
View File
@@ -0,0 +1,417 @@
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")
}
/// Magic prefix marking an encrypted credential store written by this version
/// onward.
///
/// Historically the on-disk format was detected by sniffing the first byte for
/// `[`/`{`. Encrypted blobs begin with a *random* 12-byte nonce, so roughly 2 in
/// 256 (~1 in 128) saves produced a valid encrypted file whose first byte was
/// `[` (0x5B) or `{` (0x7B); those files were misread as plaintext JSON and
/// failed to load forever after. A fixed multi-byte marker cannot collide with a
/// random nonce, so detection is now exact rather than probabilistic.
const ENCRYPTED_MAGIC: &[u8] = b"ARCHYCRED1";
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")?;
decode_credentials(data_dir, &raw).await
}
/// Decode any of the three on-disk credential formats that exist in the fleet.
///
/// Detection order, and why each step is unambiguous:
///
/// 1. **Current format** — `MAGIC ‖ nonce ‖ ciphertext`. The magic is a fixed
/// 10-byte literal, so this test has no false positives and no false
/// negatives.
/// 2. **Legacy encrypted** (no magic) — `nonce ‖ ciphertext`. Detected by
/// *successful AEAD decryption*, not by byte shape. ChaCha20-Poly1305 is
/// authenticated: a successful `decrypt` means the 16-byte Poly1305 tag
/// verified under the node key, which a non-ciphertext file passes only with
/// probability ~2^-128. This is a cryptographic discriminator, strictly
/// stronger than any structural sniff.
/// 3. **Legacy plaintext JSON** (original migration path) — reached only when
/// the bytes did not authenticate, then parsed strictly as a whole document.
///
/// If none match we return an error rather than a default store, so a
/// transiently unreadable file is never silently replaced with empty
/// credentials on the next save (CLAUDE.md: migrations never destroy data).
async fn decode_credentials(data_dir: &Path, raw: &[u8]) -> Result<CredentialStore> {
// 1. Current format: explicit marker.
if let Some(body) = raw.strip_prefix(ENCRYPTED_MAGIC) {
let key = load_encryption_key(data_dir).await?;
let plaintext = decrypt_credentials(body, &key)?;
return serde_json::from_slice(&plaintext).context("Parsing decrypted credentials");
}
// 2. Legacy encrypted, unmarked. The node key may legitimately be absent on
// a node that only ever wrote plaintext, so a key-load failure falls
// through to the plaintext path instead of aborting.
if let Ok(key) = load_encryption_key(data_dir).await {
if let Ok(plaintext) = decrypt_credentials(raw, &key) {
return serde_json::from_slice(&plaintext)
.context("Parsing decrypted credentials (legacy unmarked)");
}
}
// 3. Legacy plaintext JSON migration path.
serde_json::from_slice(raw).context(
"Credentials file is not magic-prefixed encrypted data, does not authenticate \
as a legacy encrypted blob, and is not valid plaintext JSON — refusing to \
treat it as empty",
)
}
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. Always written in the current, magic-prefixed
// format — this is how legacy files are opportunistically upgraded: they are
// read in whatever format they are on disk, and the next save re-emits them
// marked. Nothing is ever rewritten from a read path.
let key = load_encryption_key(data_dir).await?;
let encrypted = encrypt_credentials(&data, &key)?;
let mut output = Vec::with_capacity(ENCRYPTED_MAGIC.len() + encrypted.len());
output.extend_from_slice(ENCRYPTED_MAGIC);
output.extend_from_slice(&encrypted);
fs::write(&path, output)
.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::{Digest, Sha256};
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>> {
// KEY-05: the nonce names `OsRng` and is inspected before use. Nonce reuse
// under ChaCha20-Poly1305 recovers the keystream and forges the Poly1305 tag,
// so this draw is guarded even though it is exactly at `MIN_GUARDED_LEN`.
// The deterministic-nonce seam below is untouched — only the *source* of the
// random nonce changed.
let mut nonce_bytes = [0u8; 12];
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes)
.map_err(|e| anyhow::anyhow!("Refusing to encrypt with degenerate nonce entropy: {}", e))?;
encrypt_credentials_with_nonce(data, key, nonce_bytes)
}
/// Encrypt with a caller-supplied nonce, returning `nonce ‖ ciphertext` (no
/// magic prefix — `save_credentials` adds that).
///
/// Split out from [`encrypt_credentials`] so tests can construct a blob whose
/// first byte is a specific value and exercise format detection deterministically
/// instead of waiting on a 1-in-128 random draw.
fn encrypt_credentials_with_nonce(
data: &[u8],
key: &[u8; 32],
nonce_bytes: [u8; 12],
) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
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::super::types::{CredentialProof, CredentialSubject, VerifiableCredential};
use super::*;
/// Tempdir with a deterministic `identity/node_key` so the encryption key
/// can be derived. Never a real key.
fn test_dir_with_node_key() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let identity_dir = dir.path().join("identity");
std::fs::create_dir_all(&identity_dir).unwrap();
std::fs::write(identity_dir.join("node_key"), [0xAB; 32]).unwrap();
std::fs::create_dir_all(dir.path().join(CREDENTIALS_DIR)).unwrap();
dir
}
fn sample_store(marker: &str) -> CredentialStore {
CredentialStore {
credentials: vec![VerifiableCredential {
context: vec!["https://www.w3.org/ns/credentials/v2".to_string()],
id: format!("urn:uuid:{marker}"),
credential_type: vec![
"VerifiableCredential".to_string(),
"NodeOperator".to_string(),
],
issuer: "did:key:issuer".to_string(),
credential_subject: CredentialSubject {
id: "did:key:subject".to_string(),
claims: serde_json::json!({"role": "admin"}),
},
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: "did:key:issuer#key-1".to_string(),
proof_purpose: "assertionMethod".to_string(),
proof_value: "sig".to_string(),
},
credential_status: None,
}],
}
}
#[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());
}
/// Regression: an encrypted blob whose random nonce happens to start with
/// `[` (0x5B) or `{` (0x7B) used to be misdetected as plaintext JSON, so
/// `String::from_utf8` failed and the store became permanently unreadable.
/// ~1 in 128 saves hit this. Both colliding bytes are exercised here
/// deterministically via an explicit nonce.
#[tokio::test]
async fn test_legacy_encrypted_blob_with_json_first_byte_still_loads() {
for (first_byte, label) in [(0x5Bu8, "open-bracket"), (0x7Bu8, "open-brace")] {
let dir = test_dir_with_node_key();
let key = load_encryption_key(dir.path()).await.unwrap();
// Force the collision: nonce[0] is exactly the byte the old sniffer
// treated as "this file is plaintext JSON".
let mut nonce = [0u8; 12];
nonce[0] = first_byte;
let plaintext = serde_json::to_vec(&sample_store(label)).unwrap();
let blob = encrypt_credentials_with_nonce(&plaintext, &key, nonce).unwrap();
assert_eq!(
blob[0], first_byte,
"test must actually trigger the collision"
);
// Written WITHOUT magic: this is the legacy on-disk population.
std::fs::write(store_path(dir.path()), &blob).unwrap();
let loaded = load_credentials(dir.path())
.await
.unwrap_or_else(|e| panic!("{label}: colliding blob failed to load: {e:#}"));
assert_eq!(loaded.credentials.len(), 1, "{label}");
assert_eq!(loaded.credentials[0].id, format!("urn:uuid:{label}"));
}
}
/// Same collision, but in the current magic-prefixed format.
#[tokio::test]
async fn test_new_format_with_colliding_nonce_loads() {
for first_byte in [0x5Bu8, 0x7Bu8] {
let dir = test_dir_with_node_key();
let key = load_encryption_key(dir.path()).await.unwrap();
let mut nonce = [0u8; 12];
nonce[0] = first_byte;
let plaintext = serde_json::to_vec(&sample_store("magic")).unwrap();
let body = encrypt_credentials_with_nonce(&plaintext, &key, nonce).unwrap();
let mut blob = ENCRYPTED_MAGIC.to_vec();
blob.extend_from_slice(&body);
std::fs::write(store_path(dir.path()), &blob).unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials[0].id, "urn:uuid:magic");
}
}
/// Population 1: legacy plaintext JSON from the original migration path.
#[tokio::test]
async fn test_legacy_plaintext_json_still_loads() {
let dir = test_dir_with_node_key();
let json = serde_json::to_vec(&sample_store("plaintext")).unwrap();
assert_eq!(json[0], b'{');
std::fs::write(store_path(dir.path()), &json).unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials[0].id, "urn:uuid:plaintext");
}
/// Legacy plaintext must still load on a node that has no node key at all
/// (pre-onboarding), where the encrypted path cannot even derive a key.
#[tokio::test]
async fn test_legacy_plaintext_json_loads_without_node_key() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(CREDENTIALS_DIR)).unwrap();
let json = serde_json::to_vec(&sample_store("nokey")).unwrap();
std::fs::write(store_path(dir.path()), &json).unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials[0].id, "urn:uuid:nokey");
}
/// Population 2: legacy encrypted with an ordinary (non-colliding) nonce.
#[tokio::test]
async fn test_legacy_encrypted_without_magic_still_loads() {
let dir = test_dir_with_node_key();
let key = load_encryption_key(dir.path()).await.unwrap();
let plaintext = serde_json::to_vec(&sample_store("legacy-enc")).unwrap();
let blob = encrypt_credentials_with_nonce(&plaintext, &key, [0x01; 12]).unwrap();
std::fs::write(store_path(dir.path()), &blob).unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials[0].id, "urn:uuid:legacy-enc");
}
/// Population 3: current format round-trips and is actually marked on disk.
#[tokio::test]
async fn test_new_format_roundtrip_and_is_magic_prefixed() {
let dir = test_dir_with_node_key();
save_credentials(dir.path(), &sample_store("current"))
.await
.unwrap();
let on_disk = std::fs::read(store_path(dir.path())).unwrap();
assert!(
on_disk.starts_with(ENCRYPTED_MAGIC),
"save must mark the file"
);
// Still genuinely encrypted, not plaintext.
assert!(!on_disk.windows(4).any(|w| w == b"did:"));
let loaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(loaded.credentials[0].id, "urn:uuid:current");
}
/// Opportunistic upgrade happens on write, never on read: reading a legacy
/// file must leave it byte-identical; the next save re-emits it marked.
#[tokio::test]
async fn test_legacy_file_upgraded_on_write_not_on_read() {
let dir = test_dir_with_node_key();
let json = serde_json::to_vec(&sample_store("upgrade")).unwrap();
std::fs::write(store_path(dir.path()), &json).unwrap();
let loaded = load_credentials(dir.path()).await.unwrap();
// Read path must not have rewritten anything.
let after_read = std::fs::read(store_path(dir.path())).unwrap();
assert_eq!(after_read, json, "read path must not rewrite the file");
save_credentials(dir.path(), &loaded).await.unwrap();
let after_write = std::fs::read(store_path(dir.path())).unwrap();
assert!(after_write.starts_with(ENCRYPTED_MAGIC));
let reloaded = load_credentials(dir.path()).await.unwrap();
assert_eq!(reloaded.credentials[0].id, "urn:uuid:upgrade");
}
/// An unreadable file must surface an error, never a silent empty store —
/// otherwise the next save would overwrite recoverable user data.
#[tokio::test]
async fn test_undecodable_file_errors_instead_of_returning_empty() {
let dir = test_dir_with_node_key();
std::fs::write(store_path(dir.path()), b"\x00\x01\x02 not json, not ours").unwrap();
assert!(load_credentials(dir.path()).await.is_err());
}
/// KEY-05 regression: a credential blob written before the entropy migration
/// must still open after it.
///
/// **Hardcoded on purpose.** Every other test in this module seals and opens
/// in the same process, which passes even if the envelope layout changed,
/// because both halves changed together. This one pins the on-disk format
/// `MAGIC ‖ nonce ‖ ciphertext ‖ tag` against bytes this crate did not
/// produce: they come from an independent RFC 8439 ChaCha20-Poly1305
/// implementation, validated first against the RFC's own §2.8.2 vector.
///
/// Key derivation pinned too: `SHA-256("archipelago-credential-store-v1" ‖
/// [0xAB; 32])`, i.e. the key `test_dir_with_node_key` produces. A change to
/// the domain separator or the derivation would fail this test, which is the
/// point — that would strand every credential store in the fleet.
#[tokio::test]
async fn opens_pre_migration_ciphertext_vector() {
const VECTOR: [u8; 56] = [
0x41, 0x52, 0x43, 0x48, 0x59, 0x43, 0x52, 0x45, 0x44, 0x31, 0x07, 0x07, 0x07, 0x07,
0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x75, 0x51, 0xf7, 0x32, 0x46, 0x8c,
0xeb, 0x45, 0x1d, 0xe4, 0x20, 0x8f, 0x02, 0xaf, 0x56, 0xfe, 0x70, 0x8d, 0xc8, 0xf8,
0x7e, 0xf2, 0xdb, 0xa2, 0x53, 0x23, 0xdb, 0x20, 0xfe, 0x15, 0x5f, 0x8e, 0x48, 0x95,
];
let dir = test_dir_with_node_key();
std::fs::write(store_path(dir.path()), VECTOR).unwrap();
let loaded = load_credentials(dir.path())
.await
.expect("pre-migration credential blob must still decrypt");
assert!(loaded.credentials.is_empty());
// And the vector really is the marked format, not something that fell
// through to the plaintext path.
assert!(VECTOR.starts_with(ENCRYPTED_MAGIC));
}
/// A magic-prefixed file that fails authentication (tampered / wrong key)
/// must error rather than fall through to another format.
#[tokio::test]
async fn test_tampered_magic_file_errors() {
let dir = test_dir_with_node_key();
save_credentials(dir.path(), &sample_store("tamper"))
.await
.unwrap();
let mut blob = std::fs::read(store_path(dir.path())).unwrap();
*blob.last_mut().unwrap() ^= 0x01;
std::fs::write(store_path(dir.path()), &blob).unwrap();
assert!(load_credentials(dir.path()).await.is_err());
}
}
+109
View File
@@ -0,0 +1,109 @@
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,
}
/// Status of a verifiable credential.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[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 {
Self::Active => write!(f, "active"),
Self::Revoked => write!(f, "revoked"),
Self::Expired => write!(f, "expired"),
}
}
}
/// 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());
}
}