heelonvault-core 1.1.0

Core cryptography and storage library for HeelonVault
Documentation
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use secrecy::{ExposeSecret, SecretBox, SecretString};
use zeroize::Zeroizing;

use crate::errors::AppError;

pub const NONCE_LEN: usize = 12;
pub const AES_256_KEY_LEN: usize = 32;
pub const KDF_SALT_LEN: usize = 32;

#[derive(Debug, Clone)]
pub struct KdfConfig {
    pub memory_cost_kib: u32,
    pub time_cost: u32,
    pub parallelism: u32,
    pub output_len: usize,
}

impl Default for KdfConfig {
    fn default() -> Self {
        Self {
            memory_cost_kib: 64 * 1024,
            time_cost: 3,
            parallelism: 1,
            output_len: AES_256_KEY_LEN,
        }
    }
}

#[derive(Debug)]
pub struct EncryptedPayload {
    pub nonce: [u8; NONCE_LEN],
    pub ciphertext: SecretBox<Vec<u8>>,
}

#[trait_variant::make(CryptoService: Send)]
pub trait LocalCryptoService {
    async fn generate_kdf_salt(&self) -> Result<SecretBox<Vec<u8>>, AppError>;
    async fn derive_key(
        &self,
        password: &SecretString,
        salt: &SecretBox<Vec<u8>>,
    ) -> Result<SecretBox<Vec<u8>>, AppError>;
    async fn encrypt(
        &self,
        plaintext: &SecretBox<Vec<u8>>,
        key: &SecretBox<Vec<u8>>,
    ) -> Result<EncryptedPayload, AppError>;
    async fn decrypt(
        &self,
        payload: &EncryptedPayload,
        key: &SecretBox<Vec<u8>>,
    ) -> Result<SecretBox<Vec<u8>>, AppError>;
}

pub struct CryptoServiceImpl {
    kdf_config: KdfConfig,
}

impl CryptoServiceImpl {
    pub fn new(kdf_config: KdfConfig) -> Self {
        Self { kdf_config }
    }

    pub fn with_defaults() -> Self {
        Self::new(KdfConfig::default())
    }

    fn argon2_instance(&self) -> Result<Argon2<'static>, AppError> {
        let params = Params::new(
            self.kdf_config.memory_cost_kib,
            self.kdf_config.time_cost,
            self.kdf_config.parallelism,
            Some(self.kdf_config.output_len),
        )
        .map_err(|err| AppError::Crypto(format!("invalid argon2 params: {err}")))?;

        Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
    }

    fn generate_nonce() -> Result<[u8; NONCE_LEN], AppError> {
        let mut nonce = [0_u8; NONCE_LEN];
        getrandom::fill(&mut nonce)
            .map_err(|err| AppError::Crypto(format!("nonce generation failed: {err}")))?;
        Ok(nonce)
    }
}

impl Default for CryptoServiceImpl {
    fn default() -> Self {
        Self::with_defaults()
    }
}

impl CryptoService for CryptoServiceImpl {
    async fn generate_kdf_salt(&self) -> Result<SecretBox<Vec<u8>>, AppError> {
        let mut salt = Zeroizing::new(vec![0_u8; KDF_SALT_LEN]);
        getrandom::fill(salt.as_mut_slice())
            .map_err(|err| AppError::Crypto(format!("salt generation failed: {err}")))?;
        Ok(SecretBox::new(Box::new(salt.to_vec())))
    }

    async fn derive_key(
        &self,
        password: &SecretString,
        salt: &SecretBox<Vec<u8>>,
    ) -> Result<SecretBox<Vec<u8>>, AppError> {
        if salt.expose_secret().is_empty() {
            return Err(AppError::Crypto("salt must not be empty".to_string()));
        }

        let argon2 = self.argon2_instance()?;
        let mut key = Zeroizing::new(vec![0_u8; self.kdf_config.output_len]);
        argon2
            .hash_password_into(
                password.expose_secret().as_bytes(),
                salt.expose_secret().as_slice(),
                key.as_mut_slice(),
            )
            .map_err(|err| AppError::Crypto(format!("argon2id derivation failed: {err}")))?;

        Ok(SecretBox::new(Box::new(key.to_vec())))
    }

    async fn encrypt(
        &self,
        plaintext: &SecretBox<Vec<u8>>,
        key: &SecretBox<Vec<u8>>,
    ) -> Result<EncryptedPayload, AppError> {
        let key_material = Zeroizing::new(key.expose_secret().clone());
        let cipher = Aes256Gcm::new_from_slice(key_material.as_slice())
            .map_err(|err| AppError::Crypto(format!("invalid aes-256 key: {err}")))?;

        let nonce = Self::generate_nonce()?;
        let nonce_ga: Nonce<U12> = nonce.into();
        let ciphertext = cipher
            .encrypt(&nonce_ga, plaintext.expose_secret().as_slice())
            .map_err(|_| AppError::Crypto("encryption failed".to_string()))?;

        Ok(EncryptedPayload {
            nonce,
            ciphertext: SecretBox::new(Box::new(ciphertext)),
        })
    }

    async fn decrypt(
        &self,
        payload: &EncryptedPayload,
        key: &SecretBox<Vec<u8>>,
    ) -> Result<SecretBox<Vec<u8>>, AppError> {
        let key_material = Zeroizing::new(key.expose_secret().clone());
        let cipher = Aes256Gcm::new_from_slice(key_material.as_slice())
            .map_err(|err| AppError::Crypto(format!("invalid aes-256 key: {err}")))?;

        let nonce_ga: Nonce<U12> = payload.nonce.into();
        let plaintext = cipher
            .decrypt(&nonce_ga, payload.ciphertext.expose_secret().as_slice())
            .map_err(|_| AppError::Crypto("decryption failed".to_string()))?;

        Ok(SecretBox::new(Box::new(plaintext)))
    }
}