ravel-core 0.1.0

Core framework for Ravel: DI container, config, env, events, cache, encryption, password hashing, structured logging
Documentation
//! Encryption utilities — AES-256-GCM encrypt/decrypt.
//!
//! Uses the application key (`APP_KEY`) for symmetric encryption.
//! The key should be a 32-byte base64-encoded string (generated by
//! `ravel key:generate`).
//!
//! # Usage
//!
//! ```rust,ignore
//! use ravel_core::crypt::Crypt;
//!
//! let crypt = Crypt::from_key("base64:abc123...")?;
//! let encrypted = crypt.encrypt(b"secret data")?;
//! let decrypted = crypt.decrypt(&encrypted)?;
//! assert_eq!(decrypted, b"secret data");
//! ```

#![allow(deprecated)]

use aes_gcm::Aes256Gcm;
use aes_gcm::aead::generic_array::GenericArray;
use aes_gcm::aead::{Aead, KeyInit};
use anyhow::{Context, Result};
use base64::Engine;
use rand::Rng;

/// The application encrypter.
///
/// Wraps an AES-256-GCM cipher keyed by the application's `APP_KEY`.
pub struct Crypt {
    cipher: Aes256Gcm,
}

impl Crypt {
    /// Create a new `Crypt` from a raw 32-byte key.
    pub fn new(key: &[u8; 32]) -> Self {
        let cipher = Aes256Gcm::new_from_slice(key).expect("valid 32-byte key");
        Self { cipher }
    }

    /// Create a `Crypt` from an APP_KEY string.
    ///
    /// Accepts the `base64:` prefix format used by `ravel key:generate`:
    ///
    /// ```rust,ignore
    /// let crypt = Crypt::from_key("base64:H4sIAAAA...")?;
    /// ```
    pub fn from_key(key: &str) -> Result<Self> {
        let encoded = key.strip_prefix("base64:").unwrap_or(key);
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(encoded)
            .context("Failed to decode APP_KEY")?;

        let key_bytes: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .context("APP_KEY must be 32 bytes (base64-encoded)")?;

        Ok(Self::new(&key_bytes))
    }

    /// Encrypt plaintext. Returns base64-encoded ciphertext with the
    /// nonce prepended.
    pub fn encrypt(&self, plaintext: &[u8]) -> Result<String> {
        let mut nonce_bytes = [0u8; 12];
        rand::thread_rng().fill(&mut nonce_bytes);
        let nonce = *GenericArray::from_slice(&nonce_bytes);

        let ciphertext = self
            .cipher
            .encrypt(&nonce, plaintext)
            .map_err(|e| anyhow::anyhow!("Encryption failed: {e}"))?;

        // Prepend nonce to ciphertext
        let mut combined = nonce_bytes.to_vec();
        combined.extend_from_slice(&ciphertext);

        Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
    }

    /// Decrypt data produced by [`encrypt`](Self::encrypt).
    pub fn decrypt(&self, encoded: &str) -> Result<Vec<u8>> {
        let combined = base64::engine::general_purpose::STANDARD
            .decode(encoded)
            .context("Failed to decode ciphertext")?;

        if combined.len() < 12 {
            anyhow::bail!("Ciphertext too short");
        }

        let (nonce_bytes, ciphertext) = combined.split_at(12);
        let nonce = *GenericArray::from_slice(nonce_bytes);

        self.cipher
            .decrypt(&nonce, ciphertext)
            .map_err(|e| anyhow::anyhow!("Decryption failed: {e}"))
    }

    /// Encrypt a serialisable value as JSON.
    pub fn encrypt_value<T: serde::Serialize>(&self, value: &T) -> Result<String> {
        let json = serde_json::to_vec(value).context("Serialisation failed")?;
        self.encrypt(&json)
    }

    /// Decrypt and deserialise a value.
    pub fn decrypt_value<T: serde::de::DeserializeOwned>(&self, encoded: &str) -> Result<T> {
        let json = self.decrypt(encoded)?;
        serde_json::from_slice(&json).context("Deserialisation failed")
    }
}

// ── Tests ────────────────────────────────────────────────────────────────

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

    fn test_key() -> [u8; 32] {
        let mut key = [0u8; 32];
        rand::thread_rng().fill(&mut key);
        key
    }

    #[test]
    fn test_encrypt_decrypt() {
        let crypt = Crypt::new(&test_key());
        let encrypted = crypt.encrypt(b"hello world").unwrap();
        let decrypted = crypt.decrypt(&encrypted).unwrap();
        assert_eq!(decrypted, b"hello world");
    }

    #[test]
    fn test_encrypt_decrypt_value() {
        let crypt = Crypt::new(&test_key());
        let encrypted = crypt.encrypt_value(&"secret message").unwrap();
        let decrypted: String = crypt.decrypt_value(&encrypted).unwrap();
        assert_eq!(decrypted, "secret message");
    }

    #[test]
    fn test_different_keys_fail() {
        let crypt_a = Crypt::new(&test_key());
        let crypt_b = Crypt::new(&test_key());
        let encrypted = crypt_a.encrypt(b"data").unwrap();
        assert!(crypt_b.decrypt(&encrypted).is_err());
    }

    #[test]
    fn test_from_key_string() {
        let raw_key = test_key();
        let encoded = format!(
            "base64:{}",
            base64::engine::general_purpose::STANDARD.encode(&raw_key)
        );
        let crypt = Crypt::from_key(&encoded).unwrap();
        let encrypted = crypt.encrypt(b"test").unwrap();
        assert_eq!(crypt.decrypt(&encrypted).unwrap(), b"test");
    }

    #[test]
    fn test_encrypted_values_differ() {
        let crypt = Crypt::new(&test_key());
        let a = crypt.encrypt(b"same data").unwrap();
        let b = crypt.encrypt(b"same data").unwrap();
        assert_ne!(a, b); // different nonces
    }
}