107 lines
3.9 KiB
Rust

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