cryptainer 1.0.2

tiny encrypted byte array container (Argon2id + AES256-GCM)
Documentation
#![cfg_attr(not(feature = "std"), no_std)]

//! Password-based authenticated encryption for byte slices and files.
//!
//! `cryptainer` derives a 256-bit key from a password with Argon2id, encrypts
//! data with AES-256-GCM, and stores the result as `salt || nonce || ciphertext`.
//!
//! # Examples
//!
//! ## Encrypt bytes
//!
//! ```
//! use cryptainer::encrypt_data;
//!
//! let mut rng = rand::rng();
//! let data = encrypt_data("password", b"hello world", &mut rng)?;
//! # Ok::<(), cryptainer::CryptError>(())
//! ```
//!
//! ## Decrypt bytes
//!
//! ```
//! use cryptainer::{decrypt_data, encrypt_data};
//!
//! let mut rng = rand::rng();
//! let encrypted = encrypt_data("password", b"hello world", &mut rng)?;
//! let mut serialized = encrypted.salt;
//! serialized.extend_from_slice(&encrypted.nonce_bytes);
//! serialized.extend_from_slice(&encrypted.ciphertext);
//! let plaintext = decrypt_data(&serialized, "password")?;
//! # Ok::<(), cryptainer::CryptError>(())
//! ```
//!
//! ## Save and load files
//!
//! ```
//! use cryptainer::{decrypt_file, encrypt_data, save_file};
//!
//! let mut rng = rand::rng();
//! let data = encrypt_data("password", b"secret", &mut rng)?;
//! save_file("secret.stuff", &data)?;
//!
//! let plaintext = decrypt_file("secret.stuff", "password")?;
//! # Ok::<(), cryptainer::CryptError>(())
//! ```

extern crate alloc;

use alloc::vec;
use alloc::vec::Vec;

#[cfg(feature = "std")]
use std::{
    fs::File,
    io::{Read, Write},
};

use aes_gcm::{
    Aes256Gcm, Nonce,
    aead::{Aead, KeyInit},
};

use argon2::Argon2;
use thiserror::Error;

use rand::{CryptoRng, Rng};

#[cfg(feature = "zeroize")]
use zeroize::Zeroizing;

/// Errors returned by encryption, decryption, and file helpers.
#[derive(Error, Debug)]
pub enum CryptError {
    /// Password hashing failed while deriving the encryption key.
    #[error("failed to hash password")]
    HashingError,

    /// AES-GCM failed during encryption or decryption.
    #[error("encryption error: {0}")]
    EncryptionError(#[from] aes_gcm::Error),

    /// A key or nonce did not have the expected length.
    #[error("invalid key or nonce length")]
    InvalidLength,

    #[cfg(feature = "std")]
    /// File I/O failed while reading or writing encrypted data.
    #[error("I/O error: {0}")]
    IoError(#[from] std::io::Error),

    /// The input buffer was not large enough or was otherwise malformed.
    #[error("invalid data format: {0}")]
    InvalidData(&'static str),
}

/// Serialized encrypted data returned by [`encrypt_data`].
#[derive(Debug)]
pub struct CryptData {
    /// AES-GCM ciphertext including the authentication tag.
    pub ciphertext: Vec<u8>,
    /// Random 16-byte Argon2 salt used to derive the key.
    pub salt: Vec<u8>,
    /// Random 12-byte AES-GCM nonce.
    pub nonce_bytes: Vec<u8>,
}

/// Encrypts `plaintext` with a key derived from `password`.
///
/// The returned value contains the salt and nonce needed to decrypt the data
/// later. The caller supplies the RNG so applications can control entropy
/// sourcing.
pub fn encrypt_data(
    password: &str,
    plaintext: &[u8],
    rng: &mut (impl Rng + CryptoRng),
) -> Result<CryptData, CryptError> {
    let mut salt = vec![0u8; 16];
    rng.fill_bytes(&mut salt);

    #[cfg(feature = "zeroize")]
    let mut derived_key = Zeroizing::new(vec![0u8; 32]);

    #[cfg(not(feature = "zeroize"))]
    let mut derived_key = vec![0u8; 32];

    Argon2::default()
        .hash_password_into(password.as_bytes(), &salt, derived_key.as_mut_slice())
        .map_err(|_| CryptError::HashingError)?;

    let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|_| CryptError::InvalidLength)?;

    let mut nonce_bytes = vec![0u8; 12];
    rng.fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::try_from(nonce_bytes.as_slice()).map_err(|_| CryptError::InvalidLength)?;

    let ciphertext = cipher.encrypt(&nonce, plaintext)?;

    Ok(CryptData {
        ciphertext,
        salt,
        nonce_bytes,
    })
}

/// Decrypts a buffer produced by [`encrypt_data`] or [`save_file`].
///
/// The buffer must be encoded as `salt (16 bytes) || nonce (12 bytes) ||
/// ciphertext`.
pub fn decrypt_data(data: &[u8], password: &str) -> Result<Vec<u8>, CryptError> {
    if data.len() < 44 {
        return Err(CryptError::InvalidData(
            "File is too short to contain valid encrypted data",
        ));
    }

    let salt = &data[0..16];
    let nonce_bytes = &data[16..28];
    let ciphertext = &data[28..];

    #[cfg(feature = "zeroize")]
    let mut derived_key = Zeroizing::new(vec![0u8; 32]);

    #[cfg(not(feature = "zeroize"))]
    let mut derived_key = vec![0u8; 32];

    Argon2::default()
        .hash_password_into(password.as_bytes(), salt, derived_key.as_mut_slice())
        .map_err(|_| CryptError::HashingError)?;

    let cipher = Aes256Gcm::new_from_slice(&derived_key).map_err(|_| CryptError::InvalidLength)?;

    let nonce = Nonce::try_from(nonce_bytes).map_err(|_| CryptError::InvalidLength)?;

    let plaintext = cipher.decrypt(&nonce, ciphertext)?;

    Ok(plaintext)
}

#[cfg(feature = "std")]
/// Writes encrypted data to disk in `salt || nonce || ciphertext` order.
pub fn save_file(file_path: &str, data: &CryptData) -> Result<(), CryptError> {
    let mut file = File::create(file_path)?;

    file.write_all(&data.salt)?;
    file.write_all(&data.nonce_bytes)?;

    file.write_all(&data.ciphertext)?;

    Ok(())
}

#[cfg(feature = "std")]
/// Reads an encrypted file from disk and decrypts it with `password`.
pub fn decrypt_file(file_path: &str, password: &str) -> Result<Vec<u8>, CryptError> {
    let mut file = File::open(file_path)?;
    let mut buffer = Vec::new();
    file.read_to_end(&mut buffer)?;

    decrypt_data(&buffer, password)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn t_encrypt_file() -> Result<(), CryptError> {
        let mut rng = rand::rng();
        let data = encrypt_data("password", "super secret stuff".as_bytes(), &mut rng)?;

        let _ = save_file("secret.stuff", &data)?;

        Ok(())
    }

    fn t_decrypt_file() -> Result<(), CryptError> {
        let data =
            alloc::string::String::from_utf8(decrypt_file("secret.stuff", "password")?).unwrap();

        assert_eq!(data, "super secret stuff");

        Ok(())
    }

    #[test]
    fn test_encrypt_decrypt() -> Result<(), CryptError> {
        t_encrypt_file()?;
        t_decrypt_file()?;

        let _ = std::fs::remove_file("secret.stuff");

        Ok(())
    }
}