cryptainer 2.1.0

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

//! # cryptainer
//!
//! Small, password-based authenticated encryption for Rust.
//!
//! `cryptainer` derives a 256-bit key from a password with Argon2id and
//! encrypts data with AES-256-GCM. It supports `no_std` with `alloc`, while
//! file I/O and typed struct encryption remain opt-in features.
//!
//! ## Feature flags
//!
//! - `std` (default) enables file helpers and standard-library integration.
//! - `derive` enables bincode-backed Serde struct encryption and the
//!   [`Cryptainer`] derive macro.
//! - `zeroize` (default) enables zeroization of derived keys and temporary
//!   serialized plaintext buffers where supported.
//!
//! # Examples
//!
//! ## Encrypt bytes
//!
//! ```
//! # #[cfg(feature = "std")]
//! # fn example() -> Result<(), cryptainer::CryptError> {
//! # use cryptainer::encrypt_data;
//!
//! let mut rng = rand::rng();
//! let data = encrypt_data("password", b"hello world", &mut rng)?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Encrypt a struct
//!
//! With the `derive` feature enabled, derive [`Cryptainer`] alongside Serde's
//! `Serialize` and `Deserialize`:
//!
//! ```ignore
//! use cryptainer::Cryptainer;
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize, Cryptainer)]
//! struct Secret {
//!     message: String,
//! }
//!
//! let mut rng = rand::rng();
//! let secret = Secret { message: "hello".into() };
//! let encrypted = secret.encrypt("password", &mut rng)?;
//! let decrypted = Secret::decrypt(&encrypted, "password")?;
//! # Ok::<(), cryptainer::CryptError>(())
//! ```
//!
//! ## Security notes
//!
//! Every encryption generates a fresh salt and nonce. The serialized format is
//! `salt (16 bytes) || nonce (12 bytes) || ciphertext`, where the ciphertext
//! includes the AES-GCM authentication tag. Password strength and password
//! transport are the responsibility of the calling application.
//!
//! When `zeroize` is enabled, derived keys and temporary serialized plaintext
//! buffers are cleared when dropped. Returned plaintext and caller-owned
//! buffers remain the caller's responsibility.
//!
//! ## Decrypt bytes
//!
//! ```
//! # #[cfg(feature = "std")]
//! # fn example() -> Result<(), cryptainer::CryptError> {
//! # use cryptainer::{decrypt_data, encrypt_data};
//!
//! let mut rng = rand::rng();
//! let encrypted = encrypt_data("password", b"hello world", &mut rng)?;
//! let plaintext = decrypt_data(&encrypted, "password")?;
//! # Ok(())
//! # }
//! ```
//!
//! ## Save and load files
//!
//! ```
//! # #[cfg(feature = "std")]
//! # fn example() -> Result<(), cryptainer::CryptError> {
//! # 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(())
//! # }
//! ```

#[cfg(feature = "derive")]
extern crate self as cryptainer;

extern crate alloc;

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

#[cfg(feature = "derive")]
pub use cryptainer_derive::Cryptainer;

#[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),

    /// Serialization or deserialization failed.
    #[cfg(feature = "derive")]
    #[error("serialization error")]
    SerializationError,
}

#[cfg(feature = "derive")]
#[doc(hidden)]
pub mod __private {
    pub use bincode;
    pub use rand;
    pub use serde;
}

/// 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 data produced by [`encrypt_data`].
pub fn decrypt_data(data: &CryptData, password: &str) -> Result<Vec<u8>, CryptError> {
    decrypt_parts(&data.salt, &data.nonce_bytes, &data.ciphertext, password)
}

#[cfg(feature = "derive")]
/// Encrypts a serializable value using bincode.
pub fn encrypt_value<T: serde::Serialize>(
    value: &T,
    password: &str,
    rng: &mut (impl Rng + CryptoRng),
) -> Result<CryptData, CryptError> {
    let bytes = bincode::serde::encode_to_vec(value, bincode::config::standard())
        .map_err(|_| CryptError::SerializationError)?;
    #[cfg(feature = "zeroize")]
    let bytes = Zeroizing::new(bytes);

    encrypt_data(password, &bytes, rng)
}

#[cfg(feature = "derive")]
/// Decrypts a bincode-serialized value.
pub fn decrypt_value<T: serde::de::DeserializeOwned>(
    data: &CryptData,
    password: &str,
) -> Result<T, CryptError> {
    let bytes = decrypt_data(data, password)?;
    #[cfg(feature = "zeroize")]
    let bytes = Zeroizing::new(bytes);

    bincode::serde::decode_from_slice(&bytes, bincode::config::standard())
        .map(|(value, _)| value)
        .map_err(|_| CryptError::SerializationError)
}

/// Decrypts serialized data encoded as `salt || nonce || ciphertext`.
pub fn decrypt_bytes(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..];

    decrypt_parts(salt, nonce_bytes, ciphertext, password)
}

fn decrypt_parts(
    salt: &[u8],
    nonce_bytes: &[u8],
    ciphertext: &[u8],
    password: &str,
) -> Result<Vec<u8>, CryptError> {
    if salt.len() != 16 || nonce_bytes.len() != 12 || ciphertext.len() < 16 {
        return Err(CryptError::InvalidData("Invalid encrypted data"));
    }

    #[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_bytes(&buffer, password)
}

#[cfg(all(test, feature = "std"))]
mod tests {
    use super::*;

    #[cfg(feature = "derive")]
    use serde::{Deserialize, Serialize};

    #[cfg(feature = "derive")]
    #[derive(Serialize, Deserialize, Cryptainer)]
    struct Secret {
        message: String,
    }

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

    #[cfg(feature = "derive")]
    #[test]
    fn test_derive_encrypt_decrypt() -> Result<(), CryptError> {
        let mut rng = rand::rng();
        let secret = Secret {
            message: "super secret".into(),
        };
        let encrypted = secret.encrypt("password", &mut rng)?;
        let decrypted = Secret::decrypt(&encrypted, "password")?;

        assert_eq!(decrypted.message, secret.message);
        Ok(())
    }
}