#![allow(unused_assignments)]
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use lazy_static::lazy_static;
use pbkdf2::pbkdf2_hmac;
use serde::{Deserialize, Serialize};
use sha2::Sha512;
use std::collections::HashMap;
use std::fs;
use std::io::IsTerminal;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
use zeroize::{ZeroizeOnDrop, Zeroizing};
const KEY_SIZE: usize = 32;
const DEFAULT_CACHE_TIMEOUT: Duration = Duration::from_secs(300);
struct CachedPassphrase {
passphrase: Zeroizing<String>,
expires_at: SystemTime,
}
lazy_static! {
static ref PASSPHRASE_CACHE: Mutex<HashMap<String, CachedPassphrase>> = Mutex::new(HashMap::new());
static ref FAILED_ATTEMPTS: Mutex<HashMap<String, FailedAttemptTracker>> = Mutex::new(HashMap::new());
static ref DERIVED_KEYS: Mutex<HashMap<String, std::sync::Arc<EncryptionKey>>> = Mutex::new(HashMap::new());
}
struct FailedAttemptTracker {
count: u32,
last_attempt: SystemTime,
lockout_until: Option<SystemTime>,
}
const NONCE_SIZE: usize = 12;
const PBKDF2_ITERATIONS: u32 = 600_000;
const SALT_SIZE: usize = 16;
const ENCRYPTION_VERSION: u8 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptionConfig {
pub enabled: bool,
pub key_file: String,
pub fips_mode: bool,
#[serde(default = "default_cache_timeout")]
pub cache_timeout_secs: u64,
}
fn default_cache_timeout() -> u64 {
300 }
impl Default for EncryptionConfig {
fn default() -> Self {
EncryptionConfig {
enabled: false,
key_file: "~/.lit/encryption.key".to_string(),
fips_mode: true,
cache_timeout_secs: default_cache_timeout(),
}
}
}
impl EncryptionConfig {
pub fn load(repo_path: &Path) -> Result<Self, String> {
let config_path = repo_path.join(".lit").join("encryption.toml");
if !config_path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read encryption config: {}", e))?;
toml::from_str(&content).map_err(|e| format!("Failed to parse encryption config: {}", e))
}
pub fn save(&self, repo_path: &Path) -> Result<(), String> {
let config_path = repo_path.join(".lit").join("encryption.toml");
let content = toml::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize encryption config: {}", e))?;
fs::write(&config_path, content)
.map_err(|e| format!("Failed to write encryption config: {}", e))
}
}
fn derived_key_id(key_file: &str, passphrase: &str) -> String {
use sha3::{Digest, Sha3_256};
let mut hasher = Sha3_256::new();
hasher.update(key_file.as_bytes());
hasher.update([0u8]); hasher.update(passphrase.as_bytes());
hex::encode(hasher.finalize())
}
fn cached_derived_key(id: &str) -> Option<std::sync::Arc<EncryptionKey>> {
DERIVED_KEYS.lock().ok()?.get(id).cloned()
}
fn remember_derived_key(id: String, key: std::sync::Arc<EncryptionKey>) {
if let Ok(mut keys) = DERIVED_KEYS.lock() {
keys.insert(id, key);
}
}
fn check_rate_limit(repo_path: &str) -> Result<(), String> {
let mut attempts = FAILED_ATTEMPTS
.lock()
.map_err(|_| "Internal error: rate-limit lock poisoned".to_string())?;
let tracker = attempts
.entry(repo_path.to_string())
.or_insert_with(|| FailedAttemptTracker {
count: 0,
last_attempt: SystemTime::now(),
lockout_until: None,
});
if let Some(lockout) = tracker.lockout_until {
if SystemTime::now() < lockout {
let remaining = lockout
.duration_since(SystemTime::now())
.unwrap_or(Duration::from_secs(0));
return Err(format!(
"Too many failed attempts. Please wait {} seconds before trying again.",
remaining.as_secs()
));
}
tracker.lockout_until = None;
tracker.count = 0;
}
if tracker.count > 0 {
let delay = Duration::from_secs(2u64.pow(tracker.count.min(5)));
if let Ok(elapsed) = tracker.last_attempt.elapsed() {
if elapsed < delay {
let remaining = delay.as_secs().saturating_sub(elapsed.as_secs());
return Err(format!(
"Please wait {} seconds between passphrase attempts.",
remaining
));
}
}
}
Ok(())
}
fn record_failed_attempt(repo_path: &str) {
let Ok(mut attempts) = FAILED_ATTEMPTS.lock() else {
return;
};
let tracker = attempts
.entry(repo_path.to_string())
.or_insert_with(|| FailedAttemptTracker {
count: 0,
last_attempt: SystemTime::now(),
lockout_until: None,
});
tracker.count += 1;
tracker.last_attempt = SystemTime::now();
if tracker.count >= 5 {
tracker.lockout_until = Some(SystemTime::now() + Duration::from_secs(300));
eprintln!("Warning: Account locked due to multiple failed attempts. Locked for 5 minutes.");
}
}
fn clear_failed_attempts(repo_path: &str) {
if let Ok(mut attempts) = FAILED_ATTEMPTS.lock() {
attempts.remove(repo_path);
}
}
#[derive(ZeroizeOnDrop)]
#[allow(unused_assignments)]
pub struct EncryptionKey {
key_bytes: [u8; KEY_SIZE],
#[zeroize(skip)]
salt: [u8; SALT_SIZE],
}
impl EncryptionKey {
pub fn from_passphrase(passphrase: &str, salt: &[u8]) -> Result<Self, String> {
#[cfg(not(test))]
validate_passphrase_strength(passphrase)?;
#[cfg(test)]
if !passphrase.starts_with("test-") {
validate_passphrase_strength(passphrase)?;
}
if salt.len() != SALT_SIZE {
return Err(format!(
"Invalid salt size: expected {}, got {}",
SALT_SIZE,
salt.len()
));
}
let mut key_bytes = [0u8; KEY_SIZE];
pbkdf2_hmac::<Sha512>(
passphrase.as_bytes(),
salt,
PBKDF2_ITERATIONS,
&mut key_bytes,
);
let mut salt_array = [0u8; SALT_SIZE];
salt_array.copy_from_slice(salt);
Ok(EncryptionKey {
key_bytes,
salt: salt_array,
})
}
pub fn generate_salt() -> [u8; SALT_SIZE] {
use aes_gcm::aead::rand_core::RngCore;
let mut salt = [0u8; SALT_SIZE];
OsRng.fill_bytes(&mut salt);
salt
}
pub fn load(key_file: &Path, passphrase: &str) -> Result<Self, String> {
let key_file_str = key_file.to_string_lossy().to_string();
#[cfg(not(test))]
check_rate_limit(&key_file_str)?;
#[cfg(test)]
if !passphrase.starts_with("test-") {
check_rate_limit(&key_file_str)?;
}
if !key_file.exists() {
return Err(
"Encryption key file not found. Initialize repository with encryption first."
.to_string(),
);
}
let encrypted_data =
fs::read(key_file).map_err(|e| format!("Failed to read key file: {}", e))?;
if encrypted_data.len() < SALT_SIZE + 1 {
return Err("Invalid key file format (too short)".to_string());
}
let salt = &encrypted_data[0..SALT_SIZE];
let version = encrypted_data[SALT_SIZE];
if version != ENCRYPTION_VERSION {
return Err(format!("Unsupported key file version: {}", version));
}
if encrypted_data.len() == SALT_SIZE + 1 {
let key = Self::from_passphrase(passphrase, salt)?;
clear_failed_attempts(&key_file_str);
return Ok(key);
}
if encrypted_data.len() < SALT_SIZE + 1 + 32 {
return Err("Invalid key file format (unexpected size)".to_string());
}
let stored_verification = &encrypted_data[SALT_SIZE + 1..SALT_SIZE + 1 + 32];
let key = Self::from_passphrase(passphrase, salt)?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"lit-passphrase-verification-v1");
hasher.update(&key.key_bytes);
let verification_hash = hasher.finalize();
use subtle::ConstantTimeEq;
if verification_hash.ct_eq(stored_verification).unwrap_u8() != 1 {
#[cfg(not(test))]
record_failed_attempt(&key_file_str);
#[cfg(test)]
if !passphrase.starts_with("test-") {
record_failed_attempt(&key_file_str);
}
std::thread::sleep(std::time::Duration::from_millis(100));
return Err("Invalid passphrase".to_string());
}
clear_failed_attempts(&key_file_str);
Ok(key)
}
pub fn save(&self, key_file_str: &str, _passphrase: &str) -> Result<(), String> {
let expanded = shellexpand::tilde(key_file_str);
let key_file = Path::new(expanded.as_ref());
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(b"lit-passphrase-verification-v1");
hasher.update(self.key_bytes);
let verification_hash = hasher.finalize();
if let Some(parent) = key_file.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create key directory: {}", e))?;
}
let mut data = Vec::new();
data.extend_from_slice(&self.salt);
data.push(ENCRYPTION_VERSION);
data.extend_from_slice(&verification_hash);
let temp_file = key_file.with_extension("tmp");
fs::write(&temp_file, &data)
.map_err(|e| format!("Failed to write temp key file: {}", e))?;
fs::rename(&temp_file, key_file)
.map_err(|e| format!("Failed to rename key file: {}", e))?;
Ok(())
}
fn as_bytes(&self) -> &[u8; KEY_SIZE] {
&self.key_bytes
}
}
const MAX_ENCRYPTIONS_PER_KEY: u64 = 1u64 << 32;
pub struct EncryptionEngine {
cipher: Aes256Gcm,
nonce_counter: AtomicU64,
}
impl EncryptionEngine {
pub fn new(key: &EncryptionKey) -> Result<Self, String> {
let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
.map_err(|e| format!("Failed to create cipher: {}", e))?;
Ok(EncryptionEngine {
cipher,
nonce_counter: AtomicU64::new(0),
})
}
#[allow(deprecated)]
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
let count = self.nonce_counter.fetch_add(1, Ordering::SeqCst);
if count >= MAX_ENCRYPTIONS_PER_KEY {
return Err(format!(
"Encryption limit exceeded ({} operations). Key rotation required for security.",
MAX_ENCRYPTIONS_PER_KEY
));
}
use aes_gcm::aead::rand_core::RngCore;
let mut nonce_bytes = [0u8; NONCE_SIZE];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, plaintext)
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut output = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
output.push(ENCRYPTION_VERSION);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
#[allow(deprecated)]
pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
if encrypted.len() < 1 + NONCE_SIZE {
return Err("Invalid encrypted data: too short".to_string());
}
let version = encrypted[0];
if version != ENCRYPTION_VERSION {
return Err(format!("Unsupported encryption version: {}", version));
}
let nonce_bytes = &encrypted[1..1 + NONCE_SIZE];
let nonce = Nonce::from_slice(nonce_bytes);
let ciphertext = &encrypted[1 + NONCE_SIZE..];
let plaintext = self
.cipher
.decrypt(nonce, ciphertext)
.map_err(|e| format!("Decryption failed (possible tampering): {}", e))?;
Ok(plaintext)
}
}
impl CachedPassphrase {
fn is_valid(&self) -> bool {
SystemTime::now() < self.expires_at
}
}
pub fn cache_passphrase(repo_path: &str, passphrase: String, timeout: Option<Duration>) {
let timeout = timeout.unwrap_or(DEFAULT_CACHE_TIMEOUT);
let expires_at = SystemTime::now() + timeout;
if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
cache.insert(
repo_path.to_string(),
CachedPassphrase {
passphrase: Zeroizing::new(passphrase),
expires_at,
},
);
}
}
pub fn get_cached_passphrase(repo_path: &str) -> Option<Zeroizing<String>> {
if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
if let Some(entry) = cache.get(repo_path) {
if entry.is_valid() {
return Some(entry.passphrase.clone());
} else {
cache.remove(repo_path);
}
}
}
None
}
pub fn clear_passphrase_cache() {
if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
cache.clear();
}
}
pub fn clear_cached_passphrase(repo_path: &str) {
if let Ok(mut cache) = PASSPHRASE_CACHE.lock() {
cache.remove(repo_path);
}
}
fn get_passphrase_non_interactive(
repo_path: &str,
config: &EncryptionConfig,
) -> Option<Zeroizing<String>> {
if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
if !pass.is_empty() {
return Some(Zeroizing::new(pass));
}
}
if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
if let Ok(pass) = std::fs::read_to_string(&path) {
let pass = pass
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
if !pass.is_empty() {
return Some(Zeroizing::new(pass));
}
}
}
if config.cache_timeout_secs > 0 {
if let Some(cached) = get_cached_passphrase(repo_path) {
return Some(cached);
}
}
None
}
pub fn prompt_for_passphrase(
repo_path: &str,
config: &EncryptionConfig,
prompt_text: &str,
) -> Result<Zeroizing<String>, String> {
if let Some(pass) = get_passphrase_non_interactive(repo_path, config) {
return Ok(pass);
}
if !std::io::stdin().is_terminal() {
return Err(
"no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
LIT_PASSPHRASE_FILE"
.to_string(),
);
}
rpassword::prompt_password(prompt_text)
.map(Zeroizing::new)
.map_err(|e| format!("Failed to read passphrase: {}", e))
}
const MIN_PASSPHRASE_LENGTH: usize = 16;
fn validate_passphrase_strength(passphrase: &str) -> Result<(), String> {
#[cfg(test)]
if passphrase.starts_with("test-") {
return Ok(());
}
if passphrase.len() < MIN_PASSPHRASE_LENGTH {
return Err(format!(
"Passphrase must be at least {} characters (recommended: 20+)",
MIN_PASSPHRASE_LENGTH
));
}
let has_upper = passphrase.chars().any(|c| c.is_uppercase());
let has_lower = passphrase.chars().any(|c| c.is_lowercase());
let has_digit = passphrase.chars().any(|c| c.is_numeric());
let has_special = passphrase.chars().any(|c| !c.is_alphanumeric());
let complexity_count = [has_upper, has_lower, has_digit, has_special]
.iter()
.filter(|&&x| x)
.count();
if complexity_count < 3 {
return Err(
"Passphrase must include at least 3 of: uppercase, lowercase, digits, special characters"
.to_string(),
);
}
Ok(())
}
pub fn prompt_for_passphrase_confirmation(prompt_text: &str) -> Result<Zeroizing<String>, String> {
if let Ok(pass) = std::env::var("LIT_PASSPHRASE") {
if !pass.is_empty() {
validate_passphrase_strength(&pass)?;
return Ok(Zeroizing::new(pass));
}
}
if let Ok(path) = std::env::var("LIT_PASSPHRASE_FILE") {
if let Ok(pass) = std::fs::read_to_string(&path) {
let pass = pass
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
if !pass.is_empty() {
validate_passphrase_strength(&pass)?;
return Ok(Zeroizing::new(pass));
}
}
}
if !std::io::stdin().is_terminal() {
return Err(
"no passphrase available and no interactive terminal; set LIT_PASSPHRASE or \
LIT_PASSPHRASE_FILE"
.to_string(),
);
}
let pass1 = rpassword::prompt_password(prompt_text)
.map_err(|e| format!("Failed to read passphrase: {}", e))?;
let pass2 = rpassword::prompt_password("Confirm passphrase: ")
.map_err(|e| format!("Failed to read passphrase confirmation: {}", e))?;
if pass1 != pass2 {
return Err("Passphrases do not match".to_string());
}
validate_passphrase_strength(&pass1)?;
Ok(Zeroizing::new(pass1))
}
pub struct EncryptionManager {
config: EncryptionConfig,
engine: Option<EncryptionEngine>,
repo_path: Option<String>,
}
impl EncryptionManager {
pub fn new(config: EncryptionConfig) -> Self {
EncryptionManager {
config,
engine: None,
repo_path: None,
}
}
pub fn new_auto(config: EncryptionConfig, repo_path: &Path) -> Self {
let mut manager = EncryptionManager::new(config);
if !manager.config.enabled {
return manager;
}
let repo = repo_path.to_string_lossy().to_string();
let Some(passphrase) = get_passphrase_non_interactive(&repo, &manager.config) else {
return manager;
};
manager.repo_path = Some(repo.clone());
if let Err(e) = manager.initialize(&passphrase) {
eprintln!("Warning: encryption is enabled but could not be unlocked: {e}");
return manager;
}
if manager.config.cache_timeout_secs > 0 {
let timeout = Duration::from_secs(manager.config.cache_timeout_secs);
cache_passphrase(&repo, (*passphrase).clone(), Some(timeout));
}
manager
}
pub fn is_encrypted_payload(data: &[u8]) -> bool {
data.first() == Some(&ENCRYPTION_VERSION)
}
pub fn initialize(&mut self, passphrase: &str) -> Result<(), String> {
if !self.config.enabled {
return Ok(());
}
let expanded = shellexpand::tilde(&self.config.key_file);
let key_file = Path::new(expanded.as_ref());
let cache_id = derived_key_id(expanded.as_ref(), passphrase);
if let Some(key) = cached_derived_key(&cache_id) {
self.engine = Some(EncryptionEngine::new(&key)?);
return Ok(());
}
let key = if key_file.exists() {
EncryptionKey::load(key_file, passphrase)?
} else {
let key = EncryptionKey::from_passphrase(passphrase, &EncryptionKey::generate_salt())?;
key.save(&self.config.key_file, passphrase)?;
key
};
let key = std::sync::Arc::new(key);
remember_derived_key(cache_id, std::sync::Arc::clone(&key));
self.engine = Some(EncryptionEngine::new(&key)?);
Ok(())
}
pub fn initialize_with_cache(
&mut self,
repo_path: &str,
passphrase: Option<&str>,
) -> Result<(), String> {
if !self.config.enabled {
return Ok(());
}
self.repo_path = Some(repo_path.to_string());
let actual_passphrase: Zeroizing<String> = if let Some(pass) = passphrase {
Zeroizing::new(pass.to_string())
} else if let Some(cached) = get_cached_passphrase(repo_path) {
cached
} else {
return Err("No passphrase provided and no valid cached passphrase found".to_string());
};
self.initialize(&actual_passphrase)?;
if self.config.cache_timeout_secs > 0 {
let timeout = Duration::from_secs(self.config.cache_timeout_secs);
cache_passphrase(repo_path, (*actual_passphrase).clone(), Some(timeout));
}
Ok(())
}
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
if !self.config.enabled {
return Ok(plaintext.to_vec());
}
match &self.engine {
Some(engine) => engine.encrypt(plaintext),
None => Err(
"Encryption not initialized. Call initialize() with passphrase first.".to_string(),
),
}
}
pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>, String> {
if !self.config.enabled {
return Ok(encrypted.to_vec());
}
match &self.engine {
Some(engine) => {
if encrypted
.first()
.is_some_and(|version| *version != ENCRYPTION_VERSION)
{
return Err(
"This data has no Lit encryption header. Encryption cannot be \
enabled for a repository that already contains unencrypted \
commits — start a new encrypted repository and import into it."
.to_string(),
);
}
engine.decrypt(encrypted)
}
None => Err(
"Encryption not initialized. Call initialize() with passphrase first.".to_string(),
),
}
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn cache_test_guard() -> std::sync::MutexGuard<'static, ()> {
CACHE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn test_key_path(label: &str) -> std::path::PathBuf {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"lit_enc_test_{}_{}_{}.key",
std::process::id(),
label,
n
));
let _ = fs::remove_file(&path);
path
}
#[test]
fn test_key_derivation() {
let passphrase = "test-passphrase-12345";
let salt = EncryptionKey::generate_salt();
let key1 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
let key2 = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
assert_eq!(key1.as_bytes(), key2.as_bytes());
}
#[test]
fn test_encryption_decryption() {
let passphrase = "test-secure-passphrase";
let salt = EncryptionKey::generate_salt();
let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
let engine = EncryptionEngine::new(&key).unwrap();
let plaintext = b"Hello, this is secret data!";
let encrypted = engine.encrypt(plaintext).unwrap();
assert_ne!(encrypted.as_slice(), plaintext);
let decrypted = engine.decrypt(&encrypted).unwrap();
assert_eq!(decrypted.as_slice(), plaintext);
}
#[test]
fn test_encryption_nonce_randomness() {
let passphrase = "test-passphrase";
let salt = EncryptionKey::generate_salt();
let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
let engine = EncryptionEngine::new(&key).unwrap();
let plaintext = b"Same data";
let encrypted1 = engine.encrypt(plaintext).unwrap();
let encrypted2 = engine.encrypt(plaintext).unwrap();
assert_ne!(encrypted1, encrypted2);
assert_eq!(engine.decrypt(&encrypted1).unwrap(), plaintext);
assert_eq!(engine.decrypt(&encrypted2).unwrap(), plaintext);
}
#[test]
fn test_tampering_detection() {
let passphrase = "test-passphrase";
let salt = EncryptionKey::generate_salt();
let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
let engine = EncryptionEngine::new(&key).unwrap();
let plaintext = b"Secret data";
let mut encrypted = engine.encrypt(plaintext).unwrap();
let len = encrypted.len();
encrypted[len - 1] ^= 0x01;
assert!(engine.decrypt(&encrypted).is_err());
}
#[test]
fn test_encryption_manager_disabled() {
let config = EncryptionConfig {
enabled: false,
..Default::default()
};
let manager = EncryptionManager::new(config);
let data = b"Some data";
assert_eq!(manager.encrypt(data).unwrap(), data);
assert_eq!(manager.decrypt(data).unwrap(), data);
}
#[test]
fn test_passphrase_caching() {
let _guard = cache_test_guard();
let repo_path = "/tmp/test-repo";
let passphrase = "cache-test-passphrase".to_string();
clear_passphrase_cache();
assert!(get_cached_passphrase(repo_path).is_none());
cache_passphrase(repo_path, passphrase.clone(), Some(Duration::from_secs(5)));
assert_eq!(&*get_cached_passphrase(repo_path).unwrap(), &passphrase);
clear_cached_passphrase(repo_path);
assert!(get_cached_passphrase(repo_path).is_none());
}
#[test]
fn test_passphrase_cache_expiration() {
let _guard = cache_test_guard();
let repo_path = "/tmp/test-repo-expire";
let passphrase = "expire-test".to_string();
clear_passphrase_cache();
cache_passphrase(
repo_path,
passphrase.clone(),
Some(Duration::from_millis(200)),
);
std::thread::sleep(Duration::from_millis(600));
assert!(get_cached_passphrase(repo_path).is_none());
}
#[test]
fn test_passphrase_cache_multiple_repos() {
let _guard = cache_test_guard();
let repo1 = "/tmp/multi-cache-repo1";
let repo2 = "/tmp/multi-cache-repo2";
let pass1 = "password1".to_string();
let pass2 = "password2".to_string();
clear_passphrase_cache();
cache_passphrase(repo1, pass1.clone(), Some(Duration::from_secs(60)));
cache_passphrase(repo2, pass2.clone(), Some(Duration::from_secs(60)));
assert_eq!(&*get_cached_passphrase(repo1).unwrap(), &pass1);
assert_eq!(&*get_cached_passphrase(repo2).unwrap(), &pass2);
}
#[test]
fn test_encryption_manager_with_cache() {
use std::env;
let _guard = cache_test_guard();
let key_file = test_key_path("manager_cache");
let temp_dir = env::temp_dir();
let repo_path = temp_dir.join("test-cache-manager");
let repo_str = repo_path.to_str().unwrap();
clear_passphrase_cache();
let config = EncryptionConfig {
enabled: true,
key_file: key_file.to_string_lossy().into_owned(),
cache_timeout_secs: 300, ..Default::default()
};
let mut manager = EncryptionManager::new(config);
let passphrase = "test-cache-manager-pass";
manager
.initialize_with_cache(repo_str, Some(passphrase))
.unwrap();
assert_eq!(&*get_cached_passphrase(repo_str).unwrap(), passphrase);
let mut manager2 = EncryptionManager::new(manager.config.clone());
manager2.initialize_with_cache(repo_str, None).unwrap();
clear_passphrase_cache();
let _ = fs::remove_file(&key_file);
}
#[test]
#[ignore]
fn test_rate_limiting() {
let key_file = test_key_path("rate_limiting");
let key_file_str = key_file.to_string_lossy().into_owned();
let passphrase = "correct-passphrase-1234567890";
let salt = EncryptionKey::generate_salt();
let key = EncryptionKey::from_passphrase(passphrase, &salt).unwrap();
key.save(&key_file_str, passphrase).unwrap();
assert!(EncryptionKey::load(&key_file, "wrong-password-111111111111").is_err());
let start = std::time::Instant::now();
let throttled = EncryptionKey::load(&key_file, "wrong-password-222222222222")
.err()
.expect("an attempt inside the backoff window must be refused");
assert!(
throttled.contains("wait"),
"expected a rate-limit refusal, got: {}",
throttled
);
assert!(
start.elapsed() < Duration::from_secs(1),
"the throttle should refuse immediately rather than block the caller"
);
std::thread::sleep(Duration::from_millis(2_100));
let correct = EncryptionKey::load(&key_file, passphrase);
assert!(
correct.is_ok(),
"the correct passphrase should be accepted once the window passes: {:?}",
correct.as_ref().err()
);
let after_reset = EncryptionKey::load(&key_file, "wrong-again-444444444444")
.err()
.expect("a wrong passphrase must still fail");
assert!(
!after_reset.contains("wait"),
"a successful load should reset the counter, got: {}",
after_reset
);
let _ = fs::remove_file(&key_file);
}
#[test]
fn test_nonces_do_not_repeat_across_engines() {
let key = EncryptionKey::from_passphrase("NonceProbe!12345", &[7u8; SALT_SIZE]).unwrap();
let mut nonces = std::collections::HashSet::new();
let mut leading_zero_runs = 0;
for _ in 0..64 {
let engine = EncryptionEngine::new(&key).unwrap();
let blob = engine.encrypt(b"same plaintext every time").unwrap();
let nonce = blob[1..1 + NONCE_SIZE].to_vec();
if nonce[..8] == [0u8; 8] {
leading_zero_runs += 1;
}
assert!(
nonces.insert(nonce),
"a nonce repeated across engines, which breaks AES-GCM"
);
}
assert!(
leading_zero_runs <= 1,
"{} of 64 nonces began with eight zero bytes, which means the \
counter is resetting rather than the nonce being random",
leading_zero_runs
);
}
}