use anyhow::{Context, Result};
use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::SigningKey;
use pkcs8::LineEnding;
use rand_core::OsRng;
use std::path::{Path, PathBuf};
use tracing::info;
use crate::config::{config_dir, FreeCycleConfig, SecurityConfig};
use base64::Engine;
use ed25519_dalek::pkcs8::DecodePublicKey;
use sha2::Digest;
use sha2::Sha256;
const SIGNING_KEY_FILENAME: &str = "freecycle_signing_key.pem";
const VERIFYING_KEY_FILENAME: &str = "freecycle_verifying_key.pem";
const TLS_CERT_FILENAME: &str = "freecycle_cert.pem";
const TLS_KEY_FILENAME: &str = "freecycle_key.pem";
pub fn resolve_keypair_dir(config: &SecurityConfig) -> PathBuf {
config
.keypair_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(config_dir)
}
pub fn resolve_cert_dir(config: &SecurityConfig) -> PathBuf {
config
.cert_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(config_dir)
}
fn signing_key_path(keypair_dir: &Path) -> PathBuf {
keypair_dir.join(SIGNING_KEY_FILENAME)
}
fn verifying_key_path(keypair_dir: &Path) -> PathBuf {
keypair_dir.join(VERIFYING_KEY_FILENAME)
}
fn tls_cert_path(cert_dir: &Path) -> PathBuf {
cert_dir.join(TLS_CERT_FILENAME)
}
fn tls_key_path(cert_dir: &Path) -> PathBuf {
cert_dir.join(TLS_KEY_FILENAME)
}
pub fn tls_cert_and_key_paths(config: &SecurityConfig) -> (PathBuf, PathBuf) {
let cert_dir = resolve_cert_dir(config);
let cert_path = tls_cert_path(&cert_dir);
let key_path = tls_key_path(&cert_dir);
(cert_path, key_path)
}
fn generate_new_keypair(keypair_dir: &Path) -> Result<SigningKey> {
std::fs::create_dir_all(keypair_dir)
.context("Failed to create keypair directory")?;
let mut secret_bytes = [0u8; 32];
use rand_core::RngCore;
OsRng.fill_bytes(&mut secret_bytes);
let signing_key = SigningKey::from_bytes(&secret_bytes);
let verifying_key = signing_key.verifying_key();
let signing_pem = signing_key
.to_pkcs8_pem(LineEnding::LF)
.context("Failed to encode signing key to PEM")?;
let signing_path = signing_key_path(keypair_dir);
std::fs::write(&signing_path, signing_pem.as_bytes())
.context("Failed to write signing key PEM file")?;
let verifying_pem = verifying_key
.to_public_key_pem(LineEnding::LF)
.context("Failed to encode verifying key to PEM")?;
let verifying_path = verifying_key_path(keypair_dir);
std::fs::write(&verifying_path, verifying_pem.as_bytes())
.context("Failed to write verifying key PEM file")?;
info!(
"Generated new Ed25519 keypair at {}",
signing_path.display()
);
Ok(signing_key)
}
fn load_existing_keypair(keypair_dir: &Path) -> Result<SigningKey> {
let signing_path = signing_key_path(keypair_dir);
let pem_data = std::fs::read_to_string(&signing_path)
.context("Failed to read signing key PEM file")?;
let signing_key = SigningKey::from_pkcs8_pem(pem_data.as_str())
.context("Failed to parse signing key from PEM")?;
Ok(signing_key)
}
pub fn ensure_keypair(config: &SecurityConfig) -> Result<SigningKey> {
let keypair_dir = resolve_keypair_dir(config);
let signing_path = signing_key_path(&keypair_dir);
let verifying_path = verifying_key_path(&keypair_dir);
let signing_exists = signing_path.exists();
let verifying_exists = verifying_path.exists();
match (signing_exists, verifying_exists) {
(true, true) => load_existing_keypair(&keypair_dir),
(false, false) => generate_new_keypair(&keypair_dir),
_ => {
tracing::warn!(
"Keypair files in partial state at {}; regenerating",
keypair_dir.display()
);
generate_new_keypair(&keypair_dir)
}
}
}
fn generate_new_tls_cert(cert_dir: &Path) -> Result<()> {
use pem::Pem;
std::fs::create_dir_all(cert_dir)
.context("Failed to create certificate directory")?;
let subject_alt_names = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"0.0.0.0".to_string(),
];
let cert_key = rcgen::generate_simple_self_signed(subject_alt_names)
.context("Failed to generate self-signed certificate")?;
let cert_der_bytes = cert_key.cert.der().as_ref().to_vec();
let cert_pem = Pem::new("CERTIFICATE".to_string(), cert_der_bytes);
let cert_pem_string = pem::encode(&cert_pem);
let key_pem = cert_key.key_pair.serialize_pem();
let cert_path = tls_cert_path(cert_dir);
std::fs::write(&cert_path, cert_pem_string)
.context("Failed to write certificate PEM file")?;
let key_path = tls_key_path(cert_dir);
std::fs::write(&key_path, key_pem)
.context("Failed to write private key PEM file")?;
info!(
"Generated new self-signed TLS certificate at {}",
cert_path.display()
);
Ok(())
}
pub fn ensure_tls_cert(config: &SecurityConfig) -> Result<bool> {
let cert_dir = resolve_cert_dir(config);
let cert_path = tls_cert_path(&cert_dir);
let key_path = tls_key_path(&cert_dir);
let cert_exists = cert_path.exists();
let key_exists = key_path.exists();
match (cert_exists, key_exists) {
(true, true) => Ok(false),
(false, false) => generate_new_tls_cert(&cert_dir).map(|()| true),
_ => {
tracing::warn!(
"TLS certificate files in partial state at {}; regenerating",
cert_dir.display()
);
generate_new_tls_cert(&cert_dir).map(|()| true)
}
}
}
pub fn ensure_identity_uuid(config: &mut FreeCycleConfig) -> Result<String> {
if let Some(ref uuid_str) = config.security.identity_uuid {
return Ok(uuid_str.clone());
}
let new_uuid = uuid::Uuid::new_v4();
let uuid_str = new_uuid.to_string();
config.security.identity_uuid = Some(uuid_str.clone());
config.save().context("Failed to save config with identity_uuid")?;
info!("Generated new server UUID: {}", uuid_str);
Ok(uuid_str)
}
fn format_gpu_fingerprint(local_ip: &str, gpu_name: &str, vram_total_mb: u64) -> String {
format!("{} with {} @ {}MB VRAM", local_ip, gpu_name, vram_total_mb)
}
pub fn build_gpu_fingerprint(config: &SecurityConfig) -> String {
if let Some(ref override_fp) = config.fingerprint_override {
return override_fp.clone();
}
let local_ip = match local_ip_address::local_ip() {
Ok(ip) => ip.to_string(),
Err(_) => "unknown".to_string(),
};
let gpu_name = match nvml_wrapper::Nvml::init() {
Ok(nvml) => {
match nvml.device_by_index(0) {
Ok(device) => {
match device.name() {
Ok(name) => name,
Err(_) => "unknown GPU".to_string(),
}
}
Err(_) => "unknown GPU".to_string(),
}
}
Err(_) => "unknown GPU".to_string(),
};
let vram_total_mb = match nvml_wrapper::Nvml::init() {
Ok(nvml) => {
match nvml.device_by_index(0) {
Ok(device) => {
match device.memory_info() {
Ok(mem_info) => mem_info.total / (1024 * 1024),
Err(_) => 0,
}
}
Err(_) => 0,
}
}
Err(_) => 0,
};
format_gpu_fingerprint(&local_ip, &gpu_name, vram_total_mb)
}
pub fn read_verifying_key_base64(config: &SecurityConfig) -> Option<String> {
let keypair_dir = resolve_keypair_dir(config);
let verifying_path = verifying_key_path(&keypair_dir);
let pem_data = std::fs::read_to_string(&verifying_path).ok()?;
let verifying_key = ed25519_dalek::VerifyingKey::from_public_key_pem(&pem_data).ok()?;
let raw_bytes = verifying_key.as_bytes();
Some(base64::engine::general_purpose::STANDARD.encode(raw_bytes))
}
pub fn read_tls_cert_fingerprint(config: &SecurityConfig) -> Option<String> {
let cert_dir = resolve_cert_dir(config);
let cert_path = tls_cert_path(&cert_dir);
let pem_data = std::fs::read_to_string(&cert_path).ok()?;
let pem = pem::parse(&pem_data).ok()?;
let der_bytes = pem.contents();
let digest = Sha256::digest(der_bytes);
Some(format!("{:x}", digest))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, Verifier};
use std::path::Path;
use tempfile::TempDir;
fn with_temp_dir<F>(f: F) -> Result<()>
where
F: Fn(&Path) -> Result<()>,
{
let temp_dir = TempDir::new()?;
f(temp_dir.path())
}
#[test]
fn test_keypair_gen_produces_valid_keys() {
with_temp_dir(|temp_dir| {
let signing_key = generate_new_keypair(temp_dir)?;
let verifying_key = signing_key.verifying_key();
let message = b"test message";
let signature = signing_key.sign(message);
assert!(verifying_key.verify(message, &signature).is_ok());
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_signing_key_pem_round_trip() {
with_temp_dir(|temp_dir| {
let signing_key_orig = generate_new_keypair(temp_dir)?;
let signing_key_loaded = load_existing_keypair(temp_dir)?;
let message = b"round trip test";
let sig1 = signing_key_orig.sign(message);
let sig2 = signing_key_loaded.sign(message);
let verify_key = signing_key_orig.verifying_key();
assert!(verify_key.verify(message, &sig1).is_ok());
assert!(verify_key.verify(message, &sig2).is_ok());
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_verifying_key_pem_round_trip() {
use ed25519_dalek::pkcs8::DecodePublicKey;
with_temp_dir(|temp_dir| {
let signing_key = generate_new_keypair(temp_dir)?;
let verifying_key_orig = signing_key.verifying_key();
let verifying_path = verifying_key_path(temp_dir);
let pem_data = std::fs::read_to_string(&verifying_path)?;
let verifying_key_loaded =
ed25519_dalek::VerifyingKey::from_public_key_pem(&pem_data)?;
let message = b"verify round trip";
let signature = signing_key.sign(message);
assert!(verifying_key_orig.verify(message, &signature).is_ok());
assert!(verifying_key_loaded.verify(message, &signature).is_ok());
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_keypair_paths_default_to_config_dir() {
let config = SecurityConfig {
keypair_path: None,
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let resolved = resolve_keypair_dir(&config);
let expected = config_dir();
assert_eq!(resolved, expected);
}
#[test]
fn test_keypair_paths_override() {
let override_path = "C:\\custom\\path";
let config = SecurityConfig {
keypair_path: Some(override_path.to_string()),
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let resolved = resolve_keypair_dir(&config);
assert_eq!(resolved, PathBuf::from(override_path));
}
#[test]
fn test_ensure_keypair_generates_on_first_run() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: Some(temp_dir.to_string_lossy().to_string()),
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let key = ensure_keypair(&config)?;
assert!(signing_key_path(temp_dir).exists());
assert!(verifying_key_path(temp_dir).exists());
let message = b"first run test";
let signature = key.sign(message);
let verifying_key = key.verifying_key();
assert!(verifying_key.verify(message, &signature).is_ok());
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_ensure_keypair_loads_existing() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: Some(temp_dir.to_string_lossy().to_string()),
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let key1 = ensure_keypair(&config)?;
let verifying_key1 = key1.verifying_key();
let key2 = ensure_keypair(&config)?;
let verifying_key2 = key2.verifying_key();
assert_eq!(
verifying_key1.to_bytes(),
verifying_key2.to_bytes()
);
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_ensure_keypair_regenerates_on_partial_state() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: Some(temp_dir.to_string_lossy().to_string()),
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let _ = ensure_keypair(&config)?;
let verifying_path = verifying_key_path(temp_dir);
std::fs::remove_file(&verifying_path)?;
assert!(signing_key_path(temp_dir).exists());
assert!(!verifying_path.exists());
let key = ensure_keypair(&config)?;
assert!(signing_key_path(temp_dir).exists());
assert!(verifying_path.exists());
let message = b"regenerated test";
let signature = key.sign(message);
let verifying_key = key.verifying_key();
assert!(verifying_key.verify(message, &signature).is_ok());
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_tls_cert_gen_produces_valid_pem() {
with_temp_dir(|temp_dir| {
generate_new_tls_cert(temp_dir)?;
let cert_path = tls_cert_path(temp_dir);
let key_path = tls_key_path(temp_dir);
assert!(cert_path.exists(), "Certificate file should exist");
assert!(key_path.exists(), "Key file should exist");
let cert_content = std::fs::read_to_string(&cert_path)?;
let key_content = std::fs::read_to_string(&key_path)?;
assert!(
cert_content.contains("-----BEGIN CERTIFICATE-----"),
"Cert should contain PEM header"
);
assert!(
key_content.contains("-----BEGIN PRIVATE KEY-----"),
"Key should contain PEM header"
);
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_ensure_tls_cert_idempotent() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: None,
cert_path: Some(temp_dir.to_string_lossy().to_string()),
identity_uuid: None,
fingerprint_override: None,
};
let regenerated1 = ensure_tls_cert(&config)?;
assert!(regenerated1, "First run should generate certificate");
let cert_path = tls_cert_path(temp_dir);
let key_path = tls_key_path(temp_dir);
assert!(cert_path.exists());
assert!(key_path.exists());
let cert_mtime1 = std::fs::metadata(&cert_path)?.modified()?;
let key_mtime1 = std::fs::metadata(&key_path)?.modified()?;
std::thread::sleep(std::time::Duration::from_millis(10));
let regenerated2 = ensure_tls_cert(&config)?;
assert!(!regenerated2, "Second run should not regenerate certificate");
let cert_mtime2 = std::fs::metadata(&cert_path)?.modified()?;
let key_mtime2 = std::fs::metadata(&key_path)?.modified()?;
assert_eq!(
cert_mtime1, cert_mtime2,
"Certificate file should not be regenerated"
);
assert_eq!(
key_mtime1, key_mtime2,
"Key file should not be regenerated"
);
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_ensure_tls_cert_regenerates_on_partial_state() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: None,
cert_path: Some(temp_dir.to_string_lossy().to_string()),
identity_uuid: None,
fingerprint_override: None,
};
let regenerated1 = ensure_tls_cert(&config)?;
assert!(regenerated1, "First run should generate certificate");
let cert_path = tls_cert_path(temp_dir);
let key_path = tls_key_path(temp_dir);
assert!(cert_path.exists());
assert!(key_path.exists());
std::fs::remove_file(&key_path)?;
assert!(cert_path.exists());
assert!(!key_path.exists());
let regenerated2 = ensure_tls_cert(&config)?;
assert!(regenerated2, "Partial state should trigger regeneration");
assert!(cert_path.exists());
assert!(key_path.exists());
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_tls_cert_path_defaults_to_config_dir() {
let config = SecurityConfig {
keypair_path: None,
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let resolved = resolve_cert_dir(&config);
let expected = config_dir();
assert_eq!(resolved, expected);
}
#[test]
fn test_tls_cert_path_override() {
let override_path = "C:\\custom\\cert\\path";
let config = SecurityConfig {
keypair_path: None,
cert_path: Some(override_path.to_string()),
identity_uuid: None,
fingerprint_override: None,
};
let resolved = resolve_cert_dir(&config);
assert_eq!(resolved, PathBuf::from(override_path));
}
#[test]
fn test_uuid_generated_on_first_run() {
use crate::config::FreeCycleConfig;
let mut config = FreeCycleConfig::default();
assert!(config.security.identity_uuid.is_none());
let uuid = ensure_identity_uuid(&mut config).expect("should generate UUID");
let parsed = uuid::Uuid::parse_str(&uuid).expect("should parse as valid UUID");
assert_eq!(parsed.to_string(), uuid);
assert_eq!(config.security.identity_uuid, Some(uuid));
}
#[test]
fn test_uuid_not_regenerated_on_second_call() {
use crate::config::FreeCycleConfig;
let mut config = FreeCycleConfig::default();
let uuid1 = ensure_identity_uuid(&mut config).expect("first call should succeed");
let uuid2 = ensure_identity_uuid(&mut config).expect("second call should succeed");
assert_eq!(uuid1, uuid2);
}
#[test]
fn test_uuid_idempotent() {
use crate::config::FreeCycleConfig;
let mut config = FreeCycleConfig::default();
let uuid1 = ensure_identity_uuid(&mut config).expect("call 1");
let uuid2 = ensure_identity_uuid(&mut config).expect("call 2");
let uuid3 = ensure_identity_uuid(&mut config).expect("call 3");
assert_eq!(uuid1, uuid2);
assert_eq!(uuid2, uuid3);
}
#[test]
fn test_fingerprint_format_structure() {
let local_ip = "192.168.1.1";
let gpu_name = "RTX 3090";
let vram_total_mb = 24576;
let fingerprint = format_gpu_fingerprint(local_ip, gpu_name, vram_total_mb);
assert_eq!(
fingerprint,
"192.168.1.1 with RTX 3090 @ 24576MB VRAM"
);
}
#[test]
fn test_fingerprint_format_zero_vram() {
let local_ip = "10.0.0.1";
let gpu_name = "unknown GPU";
let vram_total_mb = 0;
let fingerprint = format_gpu_fingerprint(local_ip, gpu_name, vram_total_mb);
assert_eq!(
fingerprint,
"10.0.0.1 with unknown GPU @ 0MB VRAM"
);
}
#[test]
fn test_fingerprint_override_is_returned_verbatim() {
let config = SecurityConfig {
keypair_path: None,
cert_path: None,
identity_uuid: None,
fingerprint_override: Some("custom fingerprint".to_string()),
};
let fingerprint = build_gpu_fingerprint(&config);
assert_eq!(fingerprint, "custom fingerprint");
}
#[test]
fn test_fingerprint_override_none_returns_string() {
let config = SecurityConfig {
keypair_path: None,
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let fingerprint = build_gpu_fingerprint(&config);
assert!(!fingerprint.is_empty());
assert!(fingerprint.contains(" with "));
assert!(fingerprint.contains("MB VRAM"));
}
#[test]
fn test_tls_cert_and_key_paths_defaults_to_config_dir() {
let config = SecurityConfig {
keypair_path: None,
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let (cert_path, key_path) = tls_cert_and_key_paths(&config);
let config_dir = crate::config::config_dir();
assert_eq!(cert_path, config_dir.join(TLS_CERT_FILENAME));
assert_eq!(key_path, config_dir.join(TLS_KEY_FILENAME));
}
#[test]
fn test_tls_cert_and_key_paths_with_override() {
let override_path = "C:\\custom\\cert\\path";
let config = SecurityConfig {
keypair_path: None,
cert_path: Some(override_path.to_string()),
identity_uuid: None,
fingerprint_override: None,
};
let (cert_path, key_path) = tls_cert_and_key_paths(&config);
assert_eq!(cert_path, PathBuf::from(override_path).join(TLS_CERT_FILENAME));
assert_eq!(key_path, PathBuf::from(override_path).join(TLS_KEY_FILENAME));
}
#[test]
fn test_read_verifying_key_base64_returns_32_byte_base64() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: Some(temp_dir.to_string_lossy().to_string()),
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let _signing_key = ensure_keypair(&config)?;
let base64_pubkey = read_verifying_key_base64(&config);
assert!(base64_pubkey.is_some(), "Should return Some(base64_string)");
let base64_str = base64_pubkey.unwrap();
let decoded = base64::engine::general_purpose::STANDARD
.decode(&base64_str)
.expect("Should decode valid base64");
assert_eq!(decoded.len(), 32, "Public key should be 32 bytes");
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_read_verifying_key_base64_returns_none_when_file_missing() {
let config = SecurityConfig {
keypair_path: Some("/nonexistent/keypair/path".to_string()),
cert_path: None,
identity_uuid: None,
fingerprint_override: None,
};
let result = read_verifying_key_base64(&config);
assert!(result.is_none(), "Should return None when keypair files don't exist");
}
#[test]
fn test_read_tls_cert_fingerprint_returns_64_hex_chars() {
with_temp_dir(|temp_dir| {
let config = SecurityConfig {
keypair_path: None,
cert_path: Some(temp_dir.to_string_lossy().to_string()),
identity_uuid: None,
fingerprint_override: None,
};
ensure_tls_cert(&config)?;
let fingerprint = read_tls_cert_fingerprint(&config);
assert!(fingerprint.is_some(), "Should return Some(hex_string)");
let fingerprint_str = fingerprint.unwrap();
assert_eq!(
fingerprint_str.len(),
64,
"SHA-256 hex digest should be 64 chars"
);
assert!(
fingerprint_str.chars().all(|c| c.is_ascii_hexdigit()),
"Should contain only hex digits"
);
assert!(
fingerprint_str.chars().all(|c| !c.is_ascii_uppercase()),
"Should be lowercase hex"
);
Ok(())
})
.expect("test should succeed");
}
#[test]
fn test_read_tls_cert_fingerprint_returns_none_when_file_missing() {
let config = SecurityConfig {
keypair_path: None,
cert_path: Some("/nonexistent/cert/path".to_string()),
identity_uuid: None,
fingerprint_override: None,
};
let result = read_tls_cert_fingerprint(&config);
assert!(result.is_none(), "Should return None when cert file doesn't exist");
}
}