Skip to main content

shield_core/
fingerprint.rs

1//! Hardware fingerprinting for device-bound encryption.
2//!
3//! Collects platform-specific hardware identifiers to create device-bound keys.
4//! Adapted from SaaSClient-SideLicensingSystem with enhanced cross-platform support.
5
6use crate::error::{Result, ShieldError};
7#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
8use std::process::Command;
9
10/// Fingerprint collection mode.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum FingerprintMode {
13    /// No hardware fingerprinting (backward compatible).
14    #[default]
15    None,
16    /// Use motherboard serial number only.
17    Motherboard,
18    /// Use CPU identifier only.
19    CPU,
20    /// Use combined motherboard + CPU (recommended).
21    Combined,
22}
23
24/// Collect hardware fingerprint based on mode.
25///
26/// # Errors
27/// Returns error if hardware identifiers cannot be collected.
28///
29/// # Platform Support
30/// - **Windows**: wmic commands (baseboard, CPU)
31/// - **Linux**: /sys/class/dmi/id, dmidecode, /proc/cpuinfo
32/// - **macOS**: `system_profiler SPHardwareDataType`
33pub fn collect_fingerprint(mode: FingerprintMode) -> Result<String> {
34    match mode {
35        FingerprintMode::None => Ok(String::new()),
36        FingerprintMode::Motherboard => get_motherboard_serial(),
37        FingerprintMode::CPU => get_cpu_id(),
38        FingerprintMode::Combined => {
39            let mut components = Vec::new();
40
41            if let Ok(mb) = get_motherboard_serial() {
42                components.push(mb);
43            }
44
45            if let Ok(cpu) = get_cpu_id() {
46                components.push(cpu);
47            }
48
49            if components.is_empty() {
50                return Err(ShieldError::FingerprintUnavailable);
51            }
52
53            // Create hash of combined components (matches SaaSClient)
54            let combined = components.join("-");
55            Ok(format!("{:x}", md5::compute(combined.as_bytes())))
56        }
57    }
58}
59
60/// Get motherboard serial number (platform-specific).
61#[cfg(target_os = "windows")]
62fn get_motherboard_serial() -> Result<String> {
63    let output = Command::new("wmic")
64        .args(&["baseboard", "get", "serialnumber", "/value"])
65        .output()
66        .map_err(|_| ShieldError::FingerprintUnavailable)?;
67
68    let output_str = String::from_utf8_lossy(&output.stdout);
69    for line in output_str.lines() {
70        if line.starts_with("SerialNumber=") {
71            let serial = line.replace("SerialNumber=", "").trim().to_string();
72            if !serial.is_empty() && serial != "To be filled by O.E.M." {
73                return Ok(serial);
74            }
75        }
76    }
77    Err(ShieldError::FingerprintUnavailable)
78}
79
80#[cfg(target_os = "linux")]
81fn get_motherboard_serial() -> Result<String> {
82    // Try DMI sysfs first (no elevated privileges needed)
83    if let Ok(content) = std::fs::read_to_string("/sys/class/dmi/id/board_serial") {
84        let serial = content.trim();
85        if !serial.is_empty() && serial != "To be filled by O.E.M." {
86            return Ok(serial.to_string());
87        }
88    }
89
90    // Fallback to dmidecode (may require sudo)
91    let output = Command::new("dmidecode")
92        .args(["-s", "baseboard-serial-number"])
93        .output()
94        .map_err(|_| ShieldError::FingerprintUnavailable)?;
95
96    let serial = String::from_utf8_lossy(&output.stdout).trim().to_string();
97    if !serial.is_empty() && serial != "To be filled by O.E.M." {
98        Ok(serial)
99    } else {
100        Err(ShieldError::FingerprintUnavailable)
101    }
102}
103
104#[cfg(target_os = "macos")]
105fn get_motherboard_serial() -> Result<String> {
106    let output = Command::new("system_profiler")
107        .args(&["SPHardwareDataType"])
108        .output()
109        .map_err(|_| ShieldError::FingerprintUnavailable)?;
110
111    let output_str = String::from_utf8_lossy(&output.stdout);
112    for line in output_str.lines() {
113        if line.contains("Serial Number") {
114            if let Some(serial) = line.split(':').nth(1) {
115                return Ok(serial.trim().to_string());
116            }
117        }
118    }
119    Err(ShieldError::FingerprintUnavailable)
120}
121
122#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
123fn get_motherboard_serial() -> Result<String> {
124    Err(ShieldError::FingerprintUnavailable)
125}
126
127/// Get CPU identifier (platform-specific).
128#[cfg(target_os = "windows")]
129fn get_cpu_id() -> Result<String> {
130    let output = Command::new("wmic")
131        .args(&["cpu", "get", "ProcessorId", "/value"])
132        .output()
133        .map_err(|_| ShieldError::FingerprintUnavailable)?;
134
135    let output_str = String::from_utf8_lossy(&output.stdout);
136    for line in output_str.lines() {
137        if line.starts_with("ProcessorId=") {
138            let cpu_id = line.replace("ProcessorId=", "").trim().to_string();
139            if !cpu_id.is_empty() {
140                return Ok(cpu_id);
141            }
142        }
143    }
144    Err(ShieldError::FingerprintUnavailable)
145}
146
147#[cfg(target_os = "linux")]
148fn get_cpu_id() -> Result<String> {
149    if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
150        // Use first processor line as identifier
151        for line in content.lines() {
152            if line.starts_with("processor") && line.contains('0') {
153                return Ok(format!("{:x}", md5::compute(line.as_bytes())));
154            }
155        }
156    }
157    Err(ShieldError::FingerprintUnavailable)
158}
159
160#[cfg(target_os = "macos")]
161fn get_cpu_id() -> Result<String> {
162    let output = Command::new("sysctl")
163        .args(&["-n", "machdep.cpu.brand_string"])
164        .output()
165        .map_err(|_| ShieldError::FingerprintUnavailable)?;
166
167    let cpu_info = String::from_utf8_lossy(&output.stdout).trim().to_string();
168    if !cpu_info.is_empty() {
169        Ok(format!("{:x}", md5::compute(cpu_info.as_bytes())))
170    } else {
171        Err(ShieldError::FingerprintUnavailable)
172    }
173}
174
175#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
176fn get_cpu_id() -> Result<String> {
177    Err(ShieldError::FingerprintUnavailable)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_none_mode() {
186        let fp = collect_fingerprint(FingerprintMode::None).unwrap();
187        assert_eq!(fp, "");
188    }
189
190    #[test]
191    fn test_combined_mode() {
192        // This test may fail on systems without accessible hardware IDs
193        // In CI/CD, consider mocking or skipping
194        match collect_fingerprint(FingerprintMode::Combined) {
195            Ok(fp) => {
196                assert!(!fp.is_empty());
197                assert_eq!(fp.len(), 32); // MD5 hex string
198            }
199            Err(ShieldError::FingerprintUnavailable) => {
200                // Expected on VMs or restricted environments
201            }
202            Err(e) => panic!("Unexpected error: {e:?}"),
203        }
204    }
205}