use anyhow::{Result, Context}; use serde::{Deserialize, Serialize}; use sha2::{Sha256, Digest}; use std::fs; use std::process::Command; use sysinfo::{System, Disks, Networks, Components}; use mac_address::get_mac_address; use tracing::{info, warn, error, debug}; /// Hardware fingerprint containing unique device identifiers #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HardwareFingerprint { pub cpu_id: String, pub board_serial: String, pub mac_addresses: Vec, pub disk_uuid: String, #[serde(skip_serializing_if = "Option::is_none")] pub tpm_attestation: Option, pub system_info: SystemInfo, pub computed_hash: String, } /// Additional system information for device registration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SystemInfo { pub hostname: String, pub os_name: String, pub os_version: String, pub kernel_version: String, pub architecture: String, pub total_memory: u64, pub available_memory: u64, pub cpu_count: usize, pub cpu_brand: String, pub disk_info: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiskInfo { pub name: String, pub mount_point: String, pub total_space: u64, pub available_space: u64, pub file_system: String, } /// Hardware fingerprinting service pub struct HardwareFingerprintService { system: System, cache: Option, } impl HardwareFingerprintService { /// Creates a new hardware fingerprinting service pub fn new() -> Self { let system = System::new_all(); Self { system, cache: None, } } /// Generates a complete hardware fingerprint pub async fn generate_fingerprint(&mut self) -> Result { // Check cache first if let Some(cached) = &self.cache { debug!("Returning cached hardware fingerprint"); return Ok(cached.clone()); } info!("Generating hardware fingerprint..."); // Refresh system information // In sysinfo 0.30, System is immutable after creation let cpu_id = self.get_cpu_id().await?; let board_serial = self.get_board_serial().await?; let mac_addresses = self.get_mac_addresses().await?; let disk_uuid = self.get_primary_disk_uuid().await?; let tpm_attestation = self.get_tpm_attestation().await.ok(); let system_info = self.collect_system_info().await?; // Compute hash from core identifiers let computed_hash = self.compute_fingerprint_hash(&cpu_id, &board_serial, &mac_addresses, &disk_uuid); let fingerprint = HardwareFingerprint { cpu_id, board_serial, mac_addresses, disk_uuid, tpm_attestation: tpm_attestation, system_info, computed_hash, }; // Cache the result self.cache = Some(fingerprint.clone()); info!("Hardware fingerprint generated successfully"); debug!("Fingerprint hash: {}", fingerprint.computed_hash); Ok(fingerprint) } /// Gets CPU identifier async fn get_cpu_id(&self) -> Result { // Try multiple approaches to get a stable CPU identifier // Method 1: Try /proc/cpuinfo on Linux #[cfg(target_os = "linux")] { if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") { for line in cpuinfo.lines() { if line.starts_with("Serial") { if let Some(serial) = line.split(':').nth(1) { let serial = serial.trim(); if !serial.is_empty() && serial != "0000000000000000" { return Ok(serial.to_string()); } } } } } } // Method 2: Try system UUID on Linux #[cfg(target_os = "linux")] { if let Ok(machine_id) = fs::read_to_string("/etc/machine-id") { let machine_id = machine_id.trim(); if !machine_id.is_empty() { return Ok(format!("machine-{}", machine_id)); } } } // Method 3: macOS system_profiler #[cfg(target_os = "macos")] { if let Ok(output) = Command::new("system_profiler") .args(&["SPHardwareDataType"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.contains("Hardware UUID:") { if let Some(uuid) = line.split(':').nth(1) { return Ok(format!("hw-{}", uuid.trim())); } } } } } // Method 4: Windows registry #[cfg(target_os = "windows")] { if let Ok(output) = Command::new("wmic") .args(&["csproduct", "get", "UUID", "/value"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.starts_with("UUID=") { let uuid = line.replace("UUID=", "").trim().to_string(); if !uuid.is_empty() && uuid != "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF" { return Ok(format!("sys-{}", uuid)); } } } } } // Fallback: Use system information let hostname = hostname::get() .context("Failed to get hostname")? .to_string_lossy() .to_string(); let cpu_info = format!("{}-{}", hostname, "generic-cpu"); let hash = Sha256::digest(cpu_info.as_bytes()); Ok(format!("cpu-{}", hex::encode(&hash[..8]))) } /// Gets board serial number async fn get_board_serial(&self) -> Result { // Method 1: DMI information on Linux #[cfg(target_os = "linux")] { if let Ok(serial) = fs::read_to_string("/sys/devices/virtual/dmi/id/board_serial") { let serial = serial.trim(); if !serial.is_empty() && serial != "To be filled by O.E.M." { return Ok(serial.to_string()); } } if let Ok(serial) = fs::read_to_string("/sys/devices/virtual/dmi/id/product_serial") { let serial = serial.trim(); if !serial.is_empty() && serial != "To be filled by O.E.M." { return Ok(serial.to_string()); } } } // Method 2: macOS system_profiler #[cfg(target_os = "macos")] { if let Ok(output) = Command::new("system_profiler") .args(&["SPHardwareDataType"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.contains("Serial Number (system):") { if let Some(serial) = line.split(':').nth(1) { return Ok(serial.trim().to_string()); } } } } } // Method 3: Windows WMI #[cfg(target_os = "windows")] { if let Ok(output) = Command::new("wmic") .args(&["baseboard", "get", "SerialNumber", "/value"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.starts_with("SerialNumber=") { let serial = line.replace("SerialNumber=", "").trim().to_string(); if !serial.is_empty() && serial != "To be filled by O.E.M." { return Ok(serial); } } } } } // Fallback: Generate from system info let fallback_data = format!("board-{}-{}", System::name().unwrap_or("unknown".to_string()), System::kernel_version().unwrap_or("unknown".to_string())); let hash = Sha256::digest(fallback_data.as_bytes()); Ok(format!("board-{}", hex::encode(&hash[..8]))) } /// Gets all MAC addresses async fn get_mac_addresses(&self) -> Result> { let mut mac_addresses = Vec::new(); // Get primary MAC address if let Ok(Some(mac)) = get_mac_address() { mac_addresses.push(format!("{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", mac.bytes()[0], mac.bytes()[1], mac.bytes()[2], mac.bytes()[3], mac.bytes()[4], mac.bytes()[5])); } // Try to get additional MAC addresses #[cfg(target_os = "linux")] { if let Ok(entries) = fs::read_dir("/sys/class/net") { for entry in entries.flatten() { let interface_name = entry.file_name().to_string_lossy().to_string(); if interface_name.starts_with("lo") || interface_name.starts_with("docker") { continue; // Skip loopback and docker interfaces } let mac_path = format!("/sys/class/net/{}/address", interface_name); if let Ok(mac_str) = fs::read_to_string(&mac_path) { let mac_str = mac_str.trim().to_uppercase(); if mac_str != "00:00:00:00:00:00" && !mac_addresses.contains(&mac_str) { mac_addresses.push(mac_str); } } } } } if mac_addresses.is_empty() { return Err(anyhow::anyhow!("No valid MAC addresses found")); } // Sort for consistency mac_addresses.sort(); Ok(mac_addresses) } /// Gets primary disk UUID async fn get_primary_disk_uuid(&self) -> Result { // Method 1: Linux filesystem UUIDs #[cfg(target_os = "linux")] { // Try to get root filesystem UUID if let Ok(fstab) = fs::read_to_string("/etc/fstab") { for line in fstab.lines() { let line = line.trim(); if line.starts_with("UUID=") && (line.contains(" / ") || line.contains("\t/\t")) { if let Some(uuid) = line.split_whitespace().next() { let uuid = uuid.replace("UUID=", ""); if !uuid.is_empty() { return Ok(uuid); } } } } } // Try blkid command if let Ok(output) = Command::new("blkid") .args(&["-s", "UUID", "-o", "value", "/dev/sda1"]) .output() { let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !uuid.is_empty() { return Ok(uuid); } } } // Method 2: macOS diskutil #[cfg(target_os = "macos")] { if let Ok(output) = Command::new("diskutil") .args(&["info", "/"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.contains("Volume UUID:") { if let Some(uuid) = line.split(':').nth(1) { return Ok(uuid.trim().to_string()); } } } } } // Method 3: Windows WMI #[cfg(target_os = "windows")] { if let Ok(output) = Command::new("wmic") .args(&["logicaldisk", "where", "caption=\"C:\"", "get", "VolumeSerialNumber", "/value"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.starts_with("VolumeSerialNumber=") { let serial = line.replace("VolumeSerialNumber=", "").trim().to_string(); if !serial.is_empty() { return Ok(format!("vol-{}", serial)); } } } } } // Fallback: Use disk information from system let disks = Disks::new_with_refreshed_list(); if let Some(disk) = disks.first() { let disk_name = disk.name().to_string_lossy(); let mount_point = disk.mount_point().to_string_lossy(); let fallback_data = format!("disk-{}-{}", disk_name, mount_point); let hash = Sha256::digest(fallback_data.as_bytes()); Ok(format!("disk-{}", hex::encode(&hash[..8]))) } else { Err(anyhow::anyhow!("No disk information available")) } } /// Gets TPM attestation (if available) async fn get_tpm_attestation(&self) -> Result { // This is a placeholder for TPM 2.0 attestation // In a production system, this would: // 1. Check if TPM 2.0 is available // 2. Generate an attestation quote // 3. Include PCR values and attestation identity key // 4. Return base64-encoded attestation data #[cfg(target_os = "linux")] { // Check if TPM is available if fs::metadata("/dev/tpm0").is_ok() || fs::metadata("/dev/tpmrm0").is_ok() { // For demonstration, return a mock attestation // In production, use tss-esapi crate or similar TPM library let mock_attestation = format!("tpm2-mock-{}", hex::encode(Sha256::digest("mock-tpm-attestation".as_bytes()))); return Ok(base64::encode(mock_attestation)); } } #[cfg(target_os = "windows")] { // Check Windows TPM if let Ok(output) = Command::new("powershell") .args(&["-Command", "Get-Tpm"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); if output_str.contains("TpmPresent") && output_str.contains("True") { let mock_attestation = format!("tpm2-win-{}", hex::encode(Sha256::digest("windows-tpm-attestation".as_bytes()))); return Ok(base64::encode(mock_attestation)); } } } Err(anyhow::anyhow!("TPM not available or not accessible")) } /// Collects additional system information async fn collect_system_info(&self) -> Result { let hostname = hostname::get() .context("Failed to get hostname")? .to_string_lossy() .to_string(); let os_name = System::name().unwrap_or("Unknown".to_string()); let os_version = System::os_version().unwrap_or("Unknown".to_string()); let kernel_version = System::kernel_version().unwrap_or("Unknown".to_string()); let architecture = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else if cfg!(target_arch = "arm") { "arm" } else { "unknown" }.to_string(); let total_memory = self.system.total_memory(); let available_memory = self.system.available_memory(); let cpu_count = num_cpus::get(); let cpu_brand = "Generic CPU".to_string(); let disks = Disks::new_with_refreshed_list(); let disk_info = disks .iter() .map(|disk| DiskInfo { name: disk.name().to_string_lossy().to_string(), mount_point: disk.mount_point().to_string_lossy().to_string(), total_space: disk.total_space(), available_space: disk.available_space(), file_system: disk.file_system().to_string_lossy().to_string(), }) .collect(); Ok(SystemInfo { hostname, os_name, os_version, kernel_version, architecture, total_memory, available_memory, cpu_count, cpu_brand, disk_info, }) } /// Computes fingerprint hash from core identifiers fn compute_fingerprint_hash(&self, cpu_id: &str, board_serial: &str, mac_addresses: &[String], disk_uuid: &str) -> String { let mut hasher = Sha256::new(); hasher.update(cpu_id); hasher.update(board_serial); for mac in mac_addresses { hasher.update(mac); } hasher.update(disk_uuid); hex::encode(hasher.finalize()) } /// Validates fingerprint integrity pub fn validate_fingerprint(&self, fingerprint: &HardwareFingerprint) -> Result { let expected_hash = self.compute_fingerprint_hash( &fingerprint.cpu_id, &fingerprint.board_serial, &fingerprint.mac_addresses, &fingerprint.disk_uuid, ); Ok(expected_hash == fingerprint.computed_hash) } } impl Default for HardwareFingerprintService { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_fingerprint_generation() { let mut service = HardwareFingerprintService::new(); let fingerprint = service.generate_fingerprint().await.unwrap(); assert!(!fingerprint.cpu_id.is_empty()); assert!(!fingerprint.board_serial.is_empty()); assert!(!fingerprint.mac_addresses.is_empty()); assert!(!fingerprint.disk_uuid.is_empty()); assert!(!fingerprint.computed_hash.is_empty()); assert_eq!(fingerprint.computed_hash.len(), 64); // SHA256 hex length } #[tokio::test] async fn test_fingerprint_consistency() { let mut service = HardwareFingerprintService::new(); let fingerprint1 = service.generate_fingerprint().await.unwrap(); let fingerprint2 = service.generate_fingerprint().await.unwrap(); // Should be identical (cached) assert_eq!(fingerprint1.computed_hash, fingerprint2.computed_hash); } #[tokio::test] async fn test_fingerprint_validation() { let mut service = HardwareFingerprintService::new(); let fingerprint = service.generate_fingerprint().await.unwrap(); assert!(service.validate_fingerprint(&fingerprint).unwrap()); // Test with modified fingerprint let mut invalid_fingerprint = fingerprint.clone(); invalid_fingerprint.cpu_id = "modified".to_string(); assert!(!service.validate_fingerprint(&invalid_fingerprint).unwrap()); } }