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 { 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().is_some_and(|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::{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> { 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> { 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()); } }