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
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, "");
|
|
}
|
|
}
|