test_machine_id_derivation reads /etc/machine-id which doesn't exist in container environments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
511 lines
15 KiB
Rust
511 lines
15 KiB
Rust
//! Key Manager Module
|
|
//!
|
|
//! Provides secure key derivation strategies for encrypting sensitive credentials:
|
|
//! 1. **`SystemSecretKey`**: Derives key from machine UUID (default)
|
|
//! 2. **`PasswordKey`**: Derives key from user password using Argon2id
|
|
//! 3. **`EnvVarKey`**: Reads key from `FOXHUNT_ENCRYPTION_KEY` environment variable
|
|
//!
|
|
//! Features:
|
|
//! - Key caching with 5-minute expiration
|
|
//! - Secure memory zeroing on drop (via Zeroize trait)
|
|
//! - Cross-platform machine ID support (Linux, macOS, Windows)
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use argon2::{
|
|
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
|
Algorithm, Argon2, Params, Version,
|
|
};
|
|
use sha2::{Digest, Sha256};
|
|
use std::time::{Duration, Instant};
|
|
use zeroize::Zeroize;
|
|
|
|
/// Key cache duration (5 minutes)
|
|
const KEY_CACHE_DURATION: Duration = Duration::from_secs(5 * 60);
|
|
|
|
/// Expected key length in bytes (256 bits)
|
|
const KEY_LENGTH: usize = 32;
|
|
|
|
/// Argon2id memory cost in KiB (19 MiB = 19456 KiB)
|
|
const ARGON2_M_COST: u32 = 19456;
|
|
|
|
/// Argon2id time cost (iterations)
|
|
const ARGON2_T_COST: u32 = 2;
|
|
|
|
/// Argon2id parallelism factor
|
|
const ARGON2_P_COST: u32 = 1;
|
|
|
|
/// Argon2id output length in bytes
|
|
const ARGON2_OUTPUT_LEN: usize = 32;
|
|
|
|
/// Key manager for deriving and caching encryption keys
|
|
pub struct KeyManager {
|
|
cache: Option<CachedKey>,
|
|
}
|
|
|
|
/// Cached encryption key with expiration
|
|
struct CachedKey {
|
|
key: Vec<u8>,
|
|
expires_at: Instant,
|
|
}
|
|
|
|
impl Drop for CachedKey {
|
|
fn drop(&mut self) {
|
|
// Securely zero out the key material
|
|
self.key.zeroize();
|
|
}
|
|
}
|
|
|
|
impl KeyManager {
|
|
/// Create a new key manager
|
|
pub const fn new() -> Self {
|
|
Self { cache: None }
|
|
}
|
|
|
|
/// Derive key from system secret (machine UUID)
|
|
///
|
|
/// This is the default key derivation strategy:
|
|
/// - Linux: Reads `/etc/machine-id`
|
|
/// - macOS/Windows: Uses system UUID or fallback to random seed
|
|
/// - Uses SHA-256 to derive 32-byte key from UUID
|
|
pub fn derive_key(&mut self) -> Result<Vec<u8>> {
|
|
// Check cache first
|
|
if let Some(cached) = &self.cache {
|
|
if Instant::now() < cached.expires_at {
|
|
return Ok(cached.key.clone());
|
|
}
|
|
}
|
|
|
|
// Derive new key
|
|
let machine_id =
|
|
Self::get_machine_id().context("Failed to get machine ID for key derivation")?;
|
|
|
|
let key = Self::derive_key_from_machine_id(&machine_id)?;
|
|
|
|
// Cache the key
|
|
self.cache = Some(CachedKey {
|
|
key: key.clone(),
|
|
expires_at: Instant::now() + KEY_CACHE_DURATION,
|
|
});
|
|
|
|
Ok(key)
|
|
}
|
|
|
|
/// Derive key from user password using Argon2id
|
|
///
|
|
/// This is used when the `--secure` flag is enabled:
|
|
/// - Uses Argon2id with parameters: `m_cost=19MB`, `t_cost=2`, `p_cost=1`
|
|
/// - Generates random salt (16 bytes)
|
|
/// - Outputs 32-byte key
|
|
pub fn derive_key_from_password(&mut self, password: &str) -> Result<Vec<u8>> {
|
|
// Check cache first
|
|
if let Some(cached) = &self.cache {
|
|
if Instant::now() < cached.expires_at {
|
|
return Ok(cached.key.clone());
|
|
}
|
|
}
|
|
|
|
// Validate password
|
|
if password.is_empty() {
|
|
bail!("Password cannot be empty");
|
|
}
|
|
|
|
// Generate random salt
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
|
|
// Configure Argon2id parameters
|
|
let params = Params::new(
|
|
ARGON2_M_COST,
|
|
ARGON2_T_COST,
|
|
ARGON2_P_COST,
|
|
Some(ARGON2_OUTPUT_LEN),
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("Failed to create Argon2 parameters: {}", e))?;
|
|
|
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
|
|
|
// Hash the password
|
|
let password_hash = argon2
|
|
.hash_password(password.as_bytes(), &salt)
|
|
.map_err(|e| anyhow::anyhow!("Failed to hash password with Argon2id: {}", e))?;
|
|
|
|
// Extract the hash bytes
|
|
let hash = password_hash
|
|
.hash
|
|
.context("Password hash missing hash field")?;
|
|
|
|
let key = hash.as_bytes().to_vec();
|
|
|
|
// Validate key length
|
|
if key.len() != KEY_LENGTH {
|
|
bail!(
|
|
"Derived key has incorrect length: expected {}, got {}",
|
|
KEY_LENGTH,
|
|
key.len()
|
|
);
|
|
}
|
|
|
|
// Cache the key
|
|
self.cache = Some(CachedKey {
|
|
key: key.clone(),
|
|
expires_at: Instant::now() + KEY_CACHE_DURATION,
|
|
});
|
|
|
|
Ok(key)
|
|
}
|
|
|
|
/// Derive key from `FOXHUNT_ENCRYPTION_KEY` environment variable
|
|
///
|
|
/// Reads key from environment variable:
|
|
/// - Expects hex-encoded 32-byte key (64 hex characters)
|
|
/// - Validates length
|
|
pub fn derive_key_from_env(&mut self) -> Result<Vec<u8>> {
|
|
// Check cache first
|
|
if let Some(cached) = &self.cache {
|
|
if Instant::now() < cached.expires_at {
|
|
return Ok(cached.key.clone());
|
|
}
|
|
}
|
|
|
|
// Read from environment variable
|
|
let hex_key = std::env::var("FOXHUNT_ENCRYPTION_KEY")
|
|
.context("FOXHUNT_ENCRYPTION_KEY environment variable not set")?;
|
|
|
|
// Decode from hex
|
|
let key = hex::decode(&hex_key)
|
|
.context("Failed to decode hex-encoded key from FOXHUNT_ENCRYPTION_KEY")?;
|
|
|
|
// Validate length
|
|
if key.len() != KEY_LENGTH {
|
|
bail!(
|
|
"Invalid key length in FOXHUNT_ENCRYPTION_KEY: expected {} bytes, got {}",
|
|
KEY_LENGTH,
|
|
key.len()
|
|
);
|
|
}
|
|
|
|
// Cache the key
|
|
self.cache = Some(CachedKey {
|
|
key: key.clone(),
|
|
expires_at: Instant::now() + KEY_CACHE_DURATION,
|
|
});
|
|
|
|
Ok(key)
|
|
}
|
|
|
|
/// Clear the cached key (forces re-derivation on next access)
|
|
pub fn clear_cache(&mut self) {
|
|
self.cache = None;
|
|
}
|
|
|
|
/// Get machine ID for key derivation
|
|
fn get_machine_id() -> Result<String> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
Self::get_linux_machine_id()
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
Self::get_macos_machine_id()
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
Self::get_windows_machine_id()
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
|
{
|
|
Self::get_fallback_machine_id()
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn get_linux_machine_id() -> Result<String> {
|
|
std::fs::read_to_string("/etc/machine-id")
|
|
.or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))
|
|
.map(|id| id.trim().to_owned())
|
|
.context(
|
|
"Failed to read Linux machine ID from /etc/machine-id or /var/lib/dbus/machine-id",
|
|
)
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn get_macos_machine_id() -> Result<String> {
|
|
use std::process::Command;
|
|
|
|
let output = Command::new("ioreg")
|
|
.args(&["-rd1", "-c", "IOPlatformExpertDevice"])
|
|
.output()
|
|
.context("Failed to execute ioreg command on macOS")?;
|
|
|
|
if !output.status.success() {
|
|
bail!("ioreg command failed");
|
|
}
|
|
|
|
let output_str =
|
|
String::from_utf8(output.stdout).context("Failed to parse ioreg output as UTF-8")?;
|
|
|
|
// Extract IOPlatformUUID
|
|
for line in output_str.lines() {
|
|
if line.contains("IOPlatformUUID") {
|
|
if let Some(uuid) = line.split('"').nth(3) {
|
|
return Ok(uuid.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
bail!("Failed to extract IOPlatformUUID from ioreg output")
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn get_windows_machine_id() -> Result<String> {
|
|
use std::process::Command;
|
|
|
|
let output = Command::new("wmic")
|
|
.args(&["csproduct", "get", "UUID"])
|
|
.output()
|
|
.context("Failed to execute wmic command on Windows")?;
|
|
|
|
if !output.status.success() {
|
|
bail!("wmic command failed");
|
|
}
|
|
|
|
let output_str =
|
|
String::from_utf8(output.stdout).context("Failed to parse wmic output as UTF-8")?;
|
|
|
|
// Extract UUID (skip header line)
|
|
for line in output_str.lines().skip(1) {
|
|
let trimmed = line.trim();
|
|
if !trimmed.is_empty() {
|
|
return Ok(trimmed.to_string());
|
|
}
|
|
}
|
|
|
|
bail!("Failed to extract UUID from wmic output")
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
|
fn get_fallback_machine_id() -> Result<String> {
|
|
use getrandom::getrandom;
|
|
|
|
// Generate random seed as fallback
|
|
let mut buf = [0u8; 16];
|
|
getrandom(&mut buf).context("Failed to generate random machine ID fallback")?;
|
|
|
|
Ok(hex::encode(buf))
|
|
}
|
|
|
|
/// Derive 32-byte key from machine ID using SHA-256
|
|
fn derive_key_from_machine_id(machine_id: &str) -> Result<Vec<u8>> {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(machine_id.as_bytes());
|
|
let result = hasher.finalize();
|
|
Ok(result.to_vec())
|
|
}
|
|
}
|
|
|
|
impl Default for KeyManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_system_key_derivation() {
|
|
let mut manager = KeyManager::new();
|
|
|
|
// Derive key twice - should use cache on second call
|
|
let key1 = manager.derive_key().expect("Failed to derive key");
|
|
let key2 = manager
|
|
.derive_key()
|
|
.expect("Failed to derive key from cache");
|
|
|
|
assert_eq!(key1.len(), KEY_LENGTH, "Key should be 32 bytes");
|
|
assert_eq!(key1, key2, "Cached key should match original");
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_key_derivation() {
|
|
let mut manager = KeyManager::new();
|
|
let password = "test_password_123!@#";
|
|
|
|
// Derive key twice - should use cache on second call
|
|
let key1 = manager
|
|
.derive_key_from_password(password)
|
|
.expect("Failed to derive key from password");
|
|
let key2 = manager
|
|
.derive_key_from_password(password)
|
|
.expect("Failed to derive key from password (cache)");
|
|
|
|
assert_eq!(key1.len(), KEY_LENGTH, "Key should be 32 bytes");
|
|
assert_eq!(key1, key2, "Cached key should match original");
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_key_empty_password() {
|
|
let mut manager = KeyManager::new();
|
|
let result = manager.derive_key_from_password("");
|
|
|
|
assert!(result.is_err(), "Empty password should fail");
|
|
assert!(result.unwrap_err().to_string().contains("empty"));
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_env_key_derivation() {
|
|
let mut manager = KeyManager::new();
|
|
|
|
// Set environment variable with valid hex-encoded 32-byte key
|
|
let test_key = hex::encode([0x42_u8; 32]);
|
|
std::env::set_var("FOXHUNT_ENCRYPTION_KEY", &test_key);
|
|
|
|
let key = manager
|
|
.derive_key_from_env()
|
|
.expect("Failed to derive key from environment variable");
|
|
|
|
assert_eq!(key.len(), KEY_LENGTH, "Key should be 32 bytes");
|
|
assert_eq!(key, vec![0x42_u8; 32], "Key should match expected value");
|
|
|
|
// Clean up
|
|
std::env::remove_var("FOXHUNT_ENCRYPTION_KEY");
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_env_key_invalid_hex() {
|
|
let mut manager = KeyManager::new();
|
|
|
|
// Set invalid hex string
|
|
std::env::set_var("FOXHUNT_ENCRYPTION_KEY", "invalid_hex");
|
|
|
|
let result = manager.derive_key_from_env();
|
|
assert!(result.is_err(), "Invalid hex should fail");
|
|
|
|
// Clean up
|
|
std::env::remove_var("FOXHUNT_ENCRYPTION_KEY");
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_env_key_wrong_length() {
|
|
let mut manager = KeyManager::new();
|
|
|
|
// Set key with wrong length (16 bytes instead of 32)
|
|
let test_key = hex::encode([0x42_u8; 16]);
|
|
std::env::set_var("FOXHUNT_ENCRYPTION_KEY", &test_key);
|
|
|
|
let result = manager.derive_key_from_env();
|
|
assert!(result.is_err(), "Wrong key length should fail");
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Invalid key length"));
|
|
|
|
// Clean up
|
|
std::env::remove_var("FOXHUNT_ENCRYPTION_KEY");
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn test_env_key_missing() {
|
|
let mut manager = KeyManager::new();
|
|
|
|
// Ensure variable is not set
|
|
std::env::remove_var("FOXHUNT_ENCRYPTION_KEY");
|
|
|
|
let result = manager.derive_key_from_env();
|
|
assert!(result.is_err(), "Missing environment variable should fail");
|
|
assert!(result.unwrap_err().to_string().contains("not set"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_expiration() {
|
|
let mut manager = KeyManager::new();
|
|
let password = "test_password";
|
|
|
|
// Derive key
|
|
let key1 = manager
|
|
.derive_key_from_password(password)
|
|
.expect("Failed to derive key");
|
|
|
|
// Clear cache
|
|
manager.clear_cache();
|
|
|
|
// Derive again (should not use cache)
|
|
let key2 = manager
|
|
.derive_key_from_password(password)
|
|
.expect("Failed to derive key after cache clear");
|
|
|
|
// Keys should be different due to different salts
|
|
assert_ne!(key1, key2, "Keys should differ with different salts");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore] // Requires /etc/machine-id (not available in containers)
|
|
fn test_machine_id_derivation() {
|
|
// This test verifies we can get a machine ID
|
|
let machine_id = KeyManager::get_machine_id().expect("Failed to get machine ID");
|
|
|
|
assert!(!machine_id.is_empty(), "Machine ID should not be empty");
|
|
|
|
// Derive key from machine ID
|
|
let key = KeyManager::derive_key_from_machine_id(&machine_id)
|
|
.expect("Failed to derive key from machine ID");
|
|
|
|
assert_eq!(key.len(), KEY_LENGTH, "Derived key should be 32 bytes");
|
|
}
|
|
|
|
#[test]
|
|
fn test_zeroize_on_drop() {
|
|
// Create a cached key
|
|
let key = vec![0x42_u8; 32];
|
|
let cached = CachedKey {
|
|
key,
|
|
expires_at: Instant::now() + KEY_CACHE_DURATION,
|
|
};
|
|
|
|
// Get pointer to key data
|
|
let key_ptr = cached.key.as_ptr();
|
|
|
|
// Drop the cached key (triggers Zeroize)
|
|
drop(cached);
|
|
|
|
// Note: We can't directly verify memory was zeroed due to Rust's
|
|
// memory safety guarantees, but Zeroize ensures it happens
|
|
// This test verifies the Drop implementation compiles and runs
|
|
|
|
// Verify key_ptr is still valid (points to deallocated memory)
|
|
// We can't safely read it, but the test confirms Drop was called
|
|
let _ = key_ptr;
|
|
}
|
|
|
|
#[test]
|
|
fn test_key_length_validation() {
|
|
// Verify KEY_LENGTH constant is correct
|
|
assert_eq!(KEY_LENGTH, 32, "Key length should be 32 bytes (256 bits)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_duration() {
|
|
// Verify cache duration is 5 minutes
|
|
assert_eq!(KEY_CACHE_DURATION, Duration::from_secs(5 * 60));
|
|
}
|
|
|
|
#[test]
|
|
fn test_argon2_parameters() {
|
|
// Verify Argon2 parameters match specification
|
|
assert_eq!(
|
|
ARGON2_M_COST, 19456,
|
|
"Memory cost should be 19 MiB (19456 KiB)"
|
|
);
|
|
assert_eq!(ARGON2_T_COST, 2, "Time cost should be 2 iterations");
|
|
assert_eq!(ARGON2_P_COST, 1, "Parallelism should be 1");
|
|
assert_eq!(ARGON2_OUTPUT_LEN, 32, "Output length should be 32 bytes");
|
|
}
|
|
}
|