foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! At-rest encryption for secrets and private text.
//!
//! [`Secret`] wraps an AES-256-GCM key derived from a passphrase (usually
//! read from an environment variable). Use it to encrypt anything you don't
//! want sitting in the database in the clear - API keys, host URLs, system
//! prompts, chat history.
//!
//! ```
//! # #[cfg(feature = "crypto")] {
//! use foukoapi::Secret;
//!
//! let secret = Secret::from_passphrase("correct horse battery staple");
//! let sealed = secret.encrypt("sk-my-secret-key").unwrap();
//! assert_ne!(sealed, "sk-my-secret-key");           // stored form is opaque
//! assert_eq!(secret.decrypt(&sealed).unwrap(), "sk-my-secret-key");
//! # }
//! ```
//!
//! The ciphertext is self-contained: a random 96-bit nonce is prepended to
//! the AES-GCM output and the whole thing is base64-encoded, so one string
//! is all you store. Decryption with the wrong key fails cleanly rather
//! than returning garbage.

use crate::{Error, Result};
use aes_gcm::{
    aead::{Aead, KeyInit},
    Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use sha2::{Digest, Sha256};

/// An AES-256-GCM encryption handle. Cheap to clone.
#[derive(Clone)]
pub struct Secret {
    key: [u8; 32],
}

impl Secret {
    /// Derive a key from an arbitrary passphrase (hashed to 32 bytes with
    /// SHA-256). Any string works; longer and more random is better.
    pub fn from_passphrase(passphrase: &str) -> Self {
        let mut hasher = Sha256::new();
        hasher.update(passphrase.as_bytes());
        let digest = hasher.finalize();
        let mut key = [0u8; 32];
        key.copy_from_slice(&digest);
        Self { key }
    }

    /// Read the passphrase from `var` and derive a key. Returns
    /// [`Error::Other`] if the variable is unset or empty, so a bot can
    /// refuse to start (or disable the feature) rather than silently using
    /// a blank key.
    pub fn from_env(var: &str) -> Result<Self> {
        match std::env::var(var) {
            Ok(v) if !v.trim().is_empty() => Ok(Self::from_passphrase(v.trim())),
            _ => Err(Error::Other(format!(
                "{var} is not set; cannot derive an encryption key"
            ))),
        }
    }

    /// Encrypt `plaintext`, returning a base64 string of `nonce || ciphertext`.
    pub fn encrypt(&self, plaintext: &str) -> Result<String> {
        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
        let nonce_bytes = random_nonce();
        let nonce = Nonce::from_slice(&nonce_bytes);
        let ct = cipher
            .encrypt(nonce, plaintext.as_bytes())
            .map_err(|_| Error::Other("encryption failed".into()))?;
        let mut out = Vec::with_capacity(nonce_bytes.len() + ct.len());
        out.extend_from_slice(&nonce_bytes);
        out.extend_from_slice(&ct);
        Ok(STANDARD.encode(out))
    }

    /// Decrypt a string produced by [`Secret::encrypt`]. Fails if the input
    /// is malformed or the key is wrong.
    pub fn decrypt(&self, sealed: &str) -> Result<String> {
        let raw = STANDARD
            .decode(sealed.as_bytes())
            .map_err(|_| Error::Other("not valid base64".into()))?;
        if raw.len() < 12 {
            return Err(Error::Other("ciphertext too short".into()));
        }
        let (nonce_bytes, ct) = raw.split_at(12);
        let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
        let plaintext = cipher
            .decrypt(Nonce::from_slice(nonce_bytes), ct)
            .map_err(|_| Error::Other("decryption failed (wrong key or corrupt data)".into()))?;
        String::from_utf8(plaintext)
            .map_err(|_| Error::Other("decrypted bytes aren't UTF-8".into()))
    }
}

impl std::fmt::Debug for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never print the key material.
        f.write_str("Secret(<redacted>)")
    }
}

fn random_nonce() -> [u8; 12] {
    use rand::RngCore;
    let mut nonce = [0u8; 12];
    rand::thread_rng().fill_bytes(&mut nonce);
    nonce
}

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

    #[test]
    fn round_trip() {
        let s = Secret::from_passphrase("hunter2");
        let sealed = s.encrypt("hello world").unwrap();
        assert_eq!(s.decrypt(&sealed).unwrap(), "hello world");
    }

    #[test]
    fn ciphertext_is_opaque_and_nondeterministic() {
        let s = Secret::from_passphrase("key");
        let a = s.encrypt("same").unwrap();
        let b = s.encrypt("same").unwrap();
        // Random nonce => two encryptions of the same text differ.
        assert_ne!(a, b);
        assert_ne!(a, "same");
        assert_eq!(s.decrypt(&a).unwrap(), "same");
        assert_eq!(s.decrypt(&b).unwrap(), "same");
    }

    #[test]
    fn wrong_key_fails() {
        let sealed = Secret::from_passphrase("right").encrypt("secret").unwrap();
        assert!(Secret::from_passphrase("wrong").decrypt(&sealed).is_err());
    }

    #[test]
    fn garbage_fails_cleanly() {
        let s = Secret::from_passphrase("k");
        assert!(s.decrypt("not base64!!!").is_err());
        assert!(s.decrypt("QQ==").is_err()); // valid base64, too short
    }

    #[test]
    fn unicode_round_trips() {
        let s = Secret::from_passphrase("ключ");
        let sealed = s.encrypt("привет 🌍 système").unwrap();
        assert_eq!(s.decrypt(&sealed).unwrap(), "привет 🌍 système");
    }
}