You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

187 lines
5.7 KiB
Rust

//! AES-256-GCM encryption service for user API keys.
//!
//! Provides authenticated encryption with unique random nonces.
//! The master key is loaded from the `MASTER_ENCRYPTION_KEY` env var
//! (64 hex characters = 32 bytes).
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit, Nonce};
use zeroize::Zeroize;
use crate::errors::AppError;
/// Master encryption key for AES-256-GCM.
///
/// Holds the raw 32-byte key in memory. The key bytes are zeroized on drop.
/// Intentionally does NOT derive `Clone` — clones would not be zeroized,
/// leaving key material in memory. Use references (`&MasterKey`) instead.
pub struct MasterKey {
key_bytes: Vec<u8>,
}
impl MasterKey {
/// Create a `MasterKey` from a 64-character hex string (32 bytes).
///
/// Returns an error if the hex string is malformed or the wrong length.
pub fn from_hex(hex_str: &str) -> Result<Self, AppError> {
let key_bytes = hex::decode(hex_str).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"Failed to decode master encryption key: {}",
e
))
})?;
if key_bytes.len() != 32 {
return Err(AppError::Internal(anyhow::anyhow!(
"Master encryption key must be exactly 32 bytes (64 hex chars), got {} bytes",
key_bytes.len()
)));
}
Ok(Self { key_bytes })
}
/// Returns the raw key bytes (for use with AES-256-GCM).
fn as_bytes(&self) -> &[u8] {
&self.key_bytes
}
}
impl Drop for MasterKey {
fn drop(&mut self) {
self.key_bytes.zeroize();
}
}
/// Encrypt plaintext using AES-256-GCM with a random 12-byte nonce.
///
/// Returns `(ciphertext, nonce)`. The nonce must be stored alongside
/// the ciphertext for decryption.
pub fn encrypt(master_key: &MasterKey, plaintext: &str) -> Result<(Vec<u8>, Vec<u8>), AppError> {
let key = Key::<Aes256Gcm>::from_slice(master_key.as_bytes());
let cipher = Aes256Gcm::new(key);
// Generate a unique 12-byte nonce using OS randomness
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.map_err(|e| AppError::Internal(anyhow::anyhow!("Encryption failed: {}", e)))?;
Ok((ciphertext, nonce.to_vec()))
}
/// Decrypt ciphertext using AES-256-GCM with the provided nonce.
///
/// Returns the decrypted plaintext string. Returns an error if the key
/// is wrong, the data is corrupted, or the nonce doesn't match.
pub fn decrypt(
master_key: &MasterKey,
ciphertext: &[u8],
nonce_bytes: &[u8],
) -> Result<String, AppError> {
let key = Key::<Aes256Gcm>::from_slice(master_key.as_bytes());
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| AppError::Internal(anyhow::anyhow!("Decryption failed: invalid key or corrupted data")))?;
String::from_utf8(plaintext)
.map_err(|_| AppError::Internal(anyhow::anyhow!("Decrypted data is not valid UTF-8")))
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: create a valid test master key (64 hex chars = 32 bytes).
fn test_master_key() -> MasterKey {
MasterKey::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap()
}
#[test]
fn encrypt_decrypt_roundtrip() {
let key = test_master_key();
let plaintext = "AIzaSyB-test-key-12345";
let (ciphertext, nonce) = encrypt(&key, plaintext).unwrap();
let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn different_nonces_each_time() {
let key = test_master_key();
let plaintext = "same-key-value";
let (_, nonce1) = encrypt(&key, plaintext).unwrap();
let (_, nonce2) = encrypt(&key, plaintext).unwrap();
assert_ne!(nonce1, nonce2, "Each encryption must produce a unique nonce");
}
#[test]
fn wrong_key_fails() {
let key1 = test_master_key();
let key2 = MasterKey::from_hex(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
)
.unwrap();
let plaintext = "secret-api-key";
let (ciphertext, nonce) = encrypt(&key1, plaintext).unwrap();
let result = decrypt(&key2, &ciphertext, &nonce);
assert!(result.is_err(), "Decryption with wrong key must fail");
}
#[test]
fn corrupted_data_fails() {
let key = test_master_key();
let plaintext = "secret-api-key";
let (mut ciphertext, nonce) = encrypt(&key, plaintext).unwrap();
// Flip a byte in the ciphertext
if let Some(byte) = ciphertext.first_mut() {
*byte ^= 0xFF;
}
let result = decrypt(&key, &ciphertext, &nonce);
assert!(result.is_err(), "Decryption of corrupted data must fail");
}
#[test]
fn invalid_hex_key_rejected() {
let result = MasterKey::from_hex("not-valid-hex");
assert!(result.is_err());
}
#[test]
fn wrong_length_key_rejected() {
// 32 hex chars = 16 bytes, too short
let result = MasterKey::from_hex("0123456789abcdef0123456789abcdef");
assert!(result.is_err());
}
#[test]
fn nonce_is_12_bytes() {
let key = test_master_key();
let (_, nonce) = encrypt(&key, "test").unwrap();
assert_eq!(nonce.len(), 12);
}
#[test]
fn empty_plaintext_roundtrip() {
let key = test_master_key();
let (ciphertext, nonce) = encrypt(&key, "").unwrap();
let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap();
assert_eq!(decrypted, "");
}
}