promocrypt_core/
machine_id.rs

1//! Machine identification for hardware binding.
2//!
3//! Collects hardware identifiers (MAC addresses, CPU ID, disk serial)
4//! and hashes them to create a stable machine ID.
5
6use ring::digest::{Context, SHA256};
7
8use crate::error::{PromocryptError, Result};
9
10/// Salt for machine ID hashing
11const MACHINE_ID_SALT: &[u8] = b"promocrypt-machine-id-v1";
12
13/// Get the machine identifier for the current system.
14///
15/// Collects hardware identifiers and hashes them to create a stable 32-byte ID.
16///
17/// # Returns
18/// 32-byte machine ID, or error if no hardware identifiers are available.
19///
20/// # Example
21///
22/// ```no_run
23/// use promocrypt_core::get_machine_id;
24///
25/// let machine_id = get_machine_id().expect("Failed to get machine ID");
26/// println!("Machine ID: {}", hex::encode(machine_id));
27/// ```
28pub fn get_machine_id() -> Result<[u8; 32]> {
29    let components = get_machine_id_components();
30
31    if components.is_empty() {
32        return Err(PromocryptError::MachineIdUnavailable(
33            "No hardware identifiers available".to_string(),
34        ));
35    }
36
37    // Combine and hash
38    let combined = components.join(":");
39    let mut context = Context::new(&SHA256);
40    context.update(combined.as_bytes());
41    context.update(MACHINE_ID_SALT);
42    let digest = context.finish();
43
44    let mut id = [0u8; 32];
45    id.copy_from_slice(digest.as_ref());
46    Ok(id)
47}
48
49/// Check if machine ID is available on this system.
50pub fn is_machine_id_available() -> bool {
51    !get_machine_id_components().is_empty()
52}
53
54/// Get individual hardware components (for debugging).
55///
56/// Returns a list of hardware identifiers found on the system.
57pub fn get_machine_id_components() -> Vec<String> {
58    let mut components = Vec::new();
59
60    // 1. MAC addresses
61    if let Some(macs) = get_mac_addresses() {
62        components.extend(macs);
63    }
64
65    // 2. CPU ID
66    if let Some(cpu_id) = get_cpu_id() {
67        components.push(cpu_id);
68    }
69
70    // 3. Disk serial
71    if let Some(disk_id) = get_disk_serial() {
72        components.push(disk_id);
73    }
74
75    components
76}
77
78/// Get MAC addresses from network interfaces.
79fn get_mac_addresses() -> Option<Vec<String>> {
80    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
81    {
82        use mac_address::mac_address_by_name;
83        use sysinfo::Networks;
84
85        let networks = Networks::new_with_refreshed_list();
86        let mut macs: Vec<String> = Vec::new();
87
88        for (interface_name, _) in networks.iter() {
89            // Skip loopback and virtual interfaces
90            if interface_name.starts_with("lo")
91                || interface_name.starts_with("veth")
92                || interface_name.starts_with("docker")
93                || interface_name.starts_with("br-")
94            {
95                continue;
96            }
97
98            if let Ok(Some(mac)) = mac_address_by_name(interface_name) {
99                let mac_str = mac.to_string();
100                // Skip zero/broadcast MACs
101                if mac_str != "00:00:00:00:00:00" && mac_str != "ff:ff:ff:ff:ff:ff" {
102                    macs.push(mac_str);
103                }
104            }
105        }
106
107        // Sort for consistency
108        macs.sort();
109        macs.dedup();
110
111        if macs.is_empty() { None } else { Some(macs) }
112    }
113
114    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
115    {
116        None
117    }
118}
119
120/// Get CPU identifier.
121fn get_cpu_id() -> Option<String> {
122    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
123    {
124        use sysinfo::System;
125
126        let sys = System::new_all();
127
128        // Combine CPU brand with core count for uniqueness
129        let cpus = sys.cpus();
130        if let Some(cpu) = cpus.first() {
131            let brand = cpu.brand().trim();
132            if !brand.is_empty() {
133                return Some(format!("{}:{}", brand, cpus.len()));
134            }
135        }
136
137        None
138    }
139
140    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
141    {
142        None
143    }
144}
145
146/// Get disk serial number.
147fn get_disk_serial() -> Option<String> {
148    #[cfg(target_os = "linux")]
149    {
150        // Try to read from /sys
151        use std::fs;
152
153        // Try common disk paths
154        for disk in ["sda", "nvme0n1", "vda", "xvda"] {
155            let serial_path = format!("/sys/block/{}/device/serial", disk);
156            if let Ok(serial) = fs::read_to_string(&serial_path) {
157                let serial = serial.trim();
158                if !serial.is_empty() {
159                    return Some(format!("disk:{}", serial));
160                }
161            }
162
163            // Try alternate path for NVMe
164            let serial_path = format!("/sys/block/{}/serial", disk);
165            if let Ok(serial) = fs::read_to_string(&serial_path) {
166                let serial = serial.trim();
167                if !serial.is_empty() {
168                    return Some(format!("disk:{}", serial));
169                }
170            }
171        }
172
173        // Fallback to machine-id
174        if let Ok(machine_id) = fs::read_to_string("/etc/machine-id") {
175            let machine_id = machine_id.trim();
176            if !machine_id.is_empty() {
177                return Some(format!("machine-id:{}", machine_id));
178            }
179        }
180
181        None
182    }
183
184    #[cfg(target_os = "macos")]
185    {
186        use std::process::Command;
187
188        // Get hardware UUID via system_profiler
189        if let Ok(output) = Command::new("system_profiler")
190            .args(["SPHardwareDataType"])
191            .output()
192        {
193            let stdout = String::from_utf8_lossy(&output.stdout);
194            for line in stdout.lines() {
195                if line.contains("Hardware UUID:")
196                    && let Some(uuid) = line.split(':').nth(1)
197                {
198                    let uuid = uuid.trim();
199                    if !uuid.is_empty() {
200                        return Some(format!("hw-uuid:{}", uuid));
201                    }
202                }
203            }
204        }
205
206        // Fallback: IOPlatformSerialNumber
207        if let Ok(output) = Command::new("ioreg")
208            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
209            .output()
210        {
211            let stdout = String::from_utf8_lossy(&output.stdout);
212            for line in stdout.lines() {
213                if line.contains("IOPlatformSerialNumber")
214                    && let Some(serial) = line.split('"').nth(3)
215                    && !serial.is_empty()
216                {
217                    return Some(format!("serial:{}", serial));
218                }
219            }
220        }
221
222        None
223    }
224
225    #[cfg(target_os = "windows")]
226    {
227        use std::process::Command;
228
229        // Get motherboard serial via WMIC
230        if let Ok(output) = Command::new("wmic")
231            .args(["baseboard", "get", "serialnumber"])
232            .output()
233        {
234            let stdout = String::from_utf8_lossy(&output.stdout);
235            for line in stdout.lines().skip(1) {
236                let serial = line.trim();
237                if !serial.is_empty() && serial != "To be filled by O.E.M." {
238                    return Some(format!("baseboard:{}", serial));
239                }
240            }
241        }
242
243        // Fallback: machine GUID from registry
244        if let Ok(output) = Command::new("reg")
245            .args([
246                "query",
247                "HKLM\\SOFTWARE\\Microsoft\\Cryptography",
248                "/v",
249                "MachineGuid",
250            ])
251            .output()
252        {
253            let stdout = String::from_utf8_lossy(&output.stdout);
254            for line in stdout.lines() {
255                if line.contains("MachineGuid") {
256                    if let Some(guid) = line.split_whitespace().last() {
257                        return Some(format!("machine-guid:{}", guid));
258                    }
259                }
260            }
261        }
262
263        None
264    }
265
266    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
267    {
268        None
269    }
270}
271
272/// Create a machine ID from custom components.
273///
274/// Useful for testing or creating machine IDs from known values.
275pub fn machine_id_from_components(components: &[&str]) -> [u8; 32] {
276    let combined = components.join(":");
277    let mut context = Context::new(&SHA256);
278    context.update(combined.as_bytes());
279    context.update(MACHINE_ID_SALT);
280    let digest = context.finish();
281
282    let mut id = [0u8; 32];
283    id.copy_from_slice(digest.as_ref());
284    id
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_machine_id_from_components() {
293        let components = ["mac:00:11:22:33:44:55", "cpu:Intel:8"];
294        let id1 = machine_id_from_components(&components);
295        let id2 = machine_id_from_components(&components);
296
297        assert_eq!(id1, id2);
298        assert_eq!(id1.len(), 32);
299    }
300
301    #[test]
302    fn test_different_components_different_id() {
303        let id1 = machine_id_from_components(&["component1"]);
304        let id2 = machine_id_from_components(&["component2"]);
305
306        assert_ne!(id1, id2);
307    }
308
309    #[test]
310    fn test_get_machine_id_components() {
311        // This test just verifies it doesn't panic
312        let components = get_machine_id_components();
313        // On CI or containers, might be empty
314        println!("Found {} components", components.len());
315        for comp in &components {
316            println!("  {}", comp);
317        }
318    }
319
320    #[test]
321    fn test_get_machine_id() {
322        // May fail in containers/CI without hardware access
323        match get_machine_id() {
324            Ok(id) => {
325                assert_eq!(id.len(), 32);
326                println!("Machine ID: {}", hex::encode(id));
327            }
328            Err(e) => {
329                println!("Could not get machine ID: {}", e);
330            }
331        }
332    }
333
334    #[test]
335    fn test_is_machine_id_available() {
336        let available = is_machine_id_available();
337        println!("Machine ID available: {}", available);
338        // Just ensure it doesn't panic
339    }
340
341    #[test]
342    fn test_machine_id_deterministic() {
343        // If machine ID is available, it should be deterministic
344        if is_machine_id_available() {
345            let id1 = get_machine_id().unwrap();
346            let id2 = get_machine_id().unwrap();
347            assert_eq!(id1, id2);
348        }
349    }
350}