Skip to main content

licenz_core/
hardware.rs

1//! Hardware detection for license binding
2//!
3//! This module provides functionality to detect hardware identifiers
4//! for binding licenses to specific machines.
5//!
6//! ## Pluggable environment
7//!
8//! Use [`HardwareEnvironment`] to supply [`HardwareInfo`] for verification and attestation.
9//! The default implementation calls [`detect_hardware`]. Integrators can provide a custom
10//! implementation (for example TPM-derived material in the license `HardwareBinding` `custom` map) without
11//! modifying this crate.
12
13use crate::license::HardwareBinding;
14use std::sync::Arc;
15
16/// Supplies a snapshot of the current machine for license binding checks.
17///
18/// Implement this type in your application or a companion crate if the default
19/// OS-visible probe ([`detect_hardware`]) is not sufficient.
20pub trait HardwareEnvironment: Send + Sync {
21    /// Current hardware identifiers as seen by the licensing layer.
22    fn snapshot(&self) -> HardwareInfo;
23}
24
25/// Default probe: MAC addresses, disk IDs, hostname, machine ID via [`detect_hardware`].
26#[derive(Debug, Clone, Copy, Default)]
27pub struct DefaultHardwareEnvironment;
28
29impl HardwareEnvironment for DefaultHardwareEnvironment {
30    fn snapshot(&self) -> HardwareInfo {
31        detect_hardware()
32    }
33}
34
35/// Fixed snapshot (tests, diagnostics, or air-gapped override).
36#[derive(Debug, Clone)]
37pub struct FixedHardwareEnvironment(pub HardwareInfo);
38
39impl HardwareEnvironment for FixedHardwareEnvironment {
40    fn snapshot(&self) -> HardwareInfo {
41        self.0.clone()
42    }
43}
44
45/// Shared default implementation used by [`LicenseVerifier`](crate::verifier::LicenseVerifier) and [`WitnessConfig`](crate::witness::WitnessConfig).
46pub fn default_hardware_environment() -> Arc<dyn HardwareEnvironment> {
47    Arc::new(DefaultHardwareEnvironment)
48}
49
50/// Detected hardware information from the current machine
51#[derive(Debug, Clone, Default)]
52pub struct HardwareInfo {
53    /// All detected MAC addresses
54    pub mac_addresses: Vec<String>,
55
56    /// Detected disk/volume serial numbers
57    pub disk_ids: Vec<String>,
58
59    /// System hostname
60    pub hostname: Option<String>,
61
62    /// Machine UUID (if available)
63    pub machine_id: Option<String>,
64}
65
66impl HardwareInfo {
67    /// Convert to a HardwareBinding (for creating hardware-bound licenses)
68    pub fn to_binding(&self) -> HardwareBinding {
69        let mut binding = HardwareBinding::new();
70
71        if !self.mac_addresses.is_empty() {
72            binding.mac_addresses = self.mac_addresses.clone();
73        }
74
75        if !self.disk_ids.is_empty() {
76            binding.disk_ids = self.disk_ids.clone();
77        }
78
79        if let Some(ref hostname) = self.hostname {
80            binding.hostnames.push(hostname.clone());
81        }
82
83        if let Some(ref machine_id) = self.machine_id {
84            binding
85                .custom
86                .insert("machine_id".to_string(), vec![machine_id.clone()]);
87        }
88
89        binding
90    }
91}
92
93/// Detect hardware information from the current machine
94///
95/// When the `hardware-detect` feature is disabled, this returns empty/None for all fields.
96pub fn detect_hardware() -> HardwareInfo {
97    #[cfg(feature = "hardware-detect")]
98    {
99        HardwareInfo {
100            mac_addresses: detect_mac_addresses(),
101            hostname: detect_hostname(),
102            disk_ids: detect_disk_ids(),
103            machine_id: detect_machine_id(),
104        }
105    }
106    #[cfg(not(feature = "hardware-detect"))]
107    {
108        HardwareInfo {
109            mac_addresses: vec![],
110            hostname: None,
111            disk_ids: vec![],
112            machine_id: None,
113        }
114    }
115}
116
117#[cfg(feature = "hardware-detect")]
118/// Detect all MAC addresses on the system
119fn detect_mac_addresses() -> Vec<String> {
120    let mut macs = Vec::new();
121
122    // Try to get MAC addresses using mac_address crate
123    if let Ok(Some(mac)) = mac_address::get_mac_address() {
124        macs.push(mac.to_string().to_uppercase());
125    }
126
127    // Also try to get all interfaces
128    if let Ok(Some(mac)) = mac_address::mac_address_by_name("eth0") {
129        let mac_str = mac.to_string().to_uppercase();
130        if !macs.contains(&mac_str) {
131            macs.push(mac_str);
132        }
133    }
134
135    // Use sysinfo for additional network info
136    use sysinfo::Networks;
137    let networks = Networks::new_with_refreshed_list();
138
139    for (interface_name, _data) in networks.iter() {
140        // Try to get MAC for each interface
141        if let Ok(Some(mac)) = mac_address::mac_address_by_name(interface_name) {
142            let mac_str = mac.to_string().to_uppercase();
143            if !macs.contains(&mac_str) && !mac_str.starts_with("00:00:00") {
144                macs.push(mac_str);
145            }
146        }
147    }
148
149    macs
150}
151
152#[cfg(feature = "hardware-detect")]
153/// Detect the system hostname
154fn detect_hostname() -> Option<String> {
155    hostname::get()
156        .ok()
157        .and_then(|h| h.into_string().ok())
158        .map(|s| s.to_lowercase())
159}
160
161#[cfg(feature = "hardware-detect")]
162/// Detect disk serial numbers
163fn detect_disk_ids() -> Vec<String> {
164    let mut disk_ids = Vec::new();
165
166    use sysinfo::Disks;
167    let disks = Disks::new_with_refreshed_list();
168
169    for disk in disks.iter() {
170        // Get the disk name/mount point as an identifier
171        let name = disk.name().to_string_lossy().to_string();
172        if !name.is_empty() && !disk_ids.contains(&name) {
173            disk_ids.push(name);
174        }
175    }
176
177    // On Linux, try to read disk serial from /sys
178    #[cfg(target_os = "linux")]
179    {
180        if let Ok(entries) = std::fs::read_dir("/sys/block") {
181            for entry in entries.flatten() {
182                let path = entry.path().join("device/serial");
183                if let Ok(serial) = std::fs::read_to_string(&path) {
184                    let serial = serial.trim().to_string();
185                    if !serial.is_empty() && !disk_ids.contains(&serial) {
186                        disk_ids.push(serial);
187                    }
188                }
189            }
190        }
191    }
192
193    disk_ids
194}
195
196#[cfg(feature = "hardware-detect")]
197/// Detect machine ID (platform-specific)
198fn detect_machine_id() -> Option<String> {
199    // Linux: /etc/machine-id
200    #[cfg(target_os = "linux")]
201    {
202        if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
203            return Some(id.trim().to_string());
204        }
205        if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
206            return Some(id.trim().to_string());
207        }
208    }
209
210    // macOS: Use IOPlatformSerialNumber
211    #[cfg(target_os = "macos")]
212    {
213        use std::process::Command;
214        if let Ok(output) = Command::new("ioreg")
215            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
216            .output()
217        {
218            let stdout = String::from_utf8_lossy(&output.stdout);
219            for line in stdout.lines() {
220                if line.contains("IOPlatformUUID") {
221                    if let Some(start) = line.find('"') {
222                        if let Some(end) = line.rfind('"') {
223                            if start < end {
224                                return Some(line[start + 1..end].to_string());
225                            }
226                        }
227                    }
228                }
229            }
230        }
231    }
232
233    // Windows: Use wmic or registry
234    #[cfg(target_os = "windows")]
235    {
236        use std::process::Command;
237        if let Ok(output) = Command::new("wmic")
238            .args(["csproduct", "get", "UUID"])
239            .output()
240        {
241            let stdout = String::from_utf8_lossy(&output.stdout);
242            for line in stdout.lines().skip(1) {
243                let uuid = line.trim();
244                if !uuid.is_empty() && uuid != "UUID" {
245                    return Some(uuid.to_string());
246                }
247            }
248        }
249    }
250
251    None
252}
253
254/// Check if the current hardware matches the binding
255pub fn verify_hardware_binding(
256    binding: &HardwareBinding,
257    current: &HardwareInfo,
258) -> Result<(), HardwareBindingError> {
259    // If no binding is set, always pass
260    if binding.is_empty() {
261        return Ok(());
262    }
263
264    // Check MAC addresses (any match is valid)
265    if !binding.mac_addresses.is_empty() {
266        let current_macs: Vec<String> = current
267            .mac_addresses
268            .iter()
269            .map(|m| m.to_uppercase())
270            .collect();
271
272        let has_match = binding
273            .mac_addresses
274            .iter()
275            .any(|bound| current_macs.contains(&bound.to_uppercase()));
276
277        if !has_match {
278            return Err(HardwareBindingError::MacAddressMismatch {
279                expected: binding.mac_addresses.clone(),
280                found: current.mac_addresses.clone(),
281            });
282        }
283    }
284
285    // Check hostnames (any match is valid)
286    if !binding.hostnames.is_empty() {
287        if let Some(ref current_hostname) = current.hostname {
288            let has_match = binding
289                .hostnames
290                .iter()
291                .any(|bound| bound.eq_ignore_ascii_case(current_hostname));
292
293            if !has_match {
294                return Err(HardwareBindingError::HostnameMismatch {
295                    expected: binding.hostnames.clone(),
296                    found: current_hostname.clone(),
297                });
298            }
299        } else {
300            return Err(HardwareBindingError::HostnameMismatch {
301                expected: binding.hostnames.clone(),
302                found: "<unknown>".to_string(),
303            });
304        }
305    }
306
307    // Check disk IDs (any match is valid)
308    if !binding.disk_ids.is_empty() {
309        let has_match = binding
310            .disk_ids
311            .iter()
312            .any(|bound| current.disk_ids.contains(bound));
313
314        if !has_match {
315            return Err(HardwareBindingError::DiskIdMismatch {
316                expected: binding.disk_ids.clone(),
317                found: current.disk_ids.clone(),
318            });
319        }
320    }
321
322    // Check custom bindings
323    for (key, expected_values) in &binding.custom {
324        if key == "machine_id" {
325            if let Some(ref current_id) = current.machine_id {
326                if !expected_values.contains(current_id) {
327                    return Err(HardwareBindingError::CustomMismatch {
328                        key: key.clone(),
329                        expected: expected_values.clone(),
330                        found: current_id.clone(),
331                    });
332                }
333            }
334        }
335    }
336
337    Ok(())
338}
339
340/// Hardware binding verification errors
341#[derive(Debug, Clone)]
342pub enum HardwareBindingError {
343    MacAddressMismatch {
344        expected: Vec<String>,
345        found: Vec<String>,
346    },
347    HostnameMismatch {
348        expected: Vec<String>,
349        found: String,
350    },
351    DiskIdMismatch {
352        expected: Vec<String>,
353        found: Vec<String>,
354    },
355    CustomMismatch {
356        key: String,
357        expected: Vec<String>,
358        found: String,
359    },
360}
361
362impl std::fmt::Display for HardwareBindingError {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        match self {
365            Self::MacAddressMismatch { expected, found } => {
366                write!(
367                    f,
368                    "MAC address mismatch: expected one of {:?}, found {:?}",
369                    expected, found
370                )
371            }
372            Self::HostnameMismatch { expected, found } => {
373                write!(
374                    f,
375                    "Hostname mismatch: expected one of {:?}, found {}",
376                    expected, found
377                )
378            }
379            Self::DiskIdMismatch { expected, found } => {
380                write!(
381                    f,
382                    "Disk ID mismatch: expected one of {:?}, found {:?}",
383                    expected, found
384                )
385            }
386            Self::CustomMismatch {
387                key,
388                expected,
389                found,
390            } => {
391                write!(
392                    f,
393                    "Custom binding '{}' mismatch: expected one of {:?}, found {}",
394                    key, expected, found
395                )
396            }
397        }
398    }
399}
400
401impl std::error::Error for HardwareBindingError {}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_empty_binding_always_passes() {
409        let binding = HardwareBinding::new();
410        let hardware = HardwareInfo::default();
411
412        assert!(verify_hardware_binding(&binding, &hardware).is_ok());
413    }
414
415    #[test]
416    fn test_mac_address_binding() {
417        let binding = HardwareBinding::new().with_mac_address("AA:BB:CC:DD:EE:FF");
418
419        let mut hardware = HardwareInfo {
420            mac_addresses: vec!["AA:BB:CC:DD:EE:FF".to_string()],
421            ..Default::default()
422        };
423
424        assert!(verify_hardware_binding(&binding, &hardware).is_ok());
425
426        hardware.mac_addresses = vec!["11:22:33:44:55:66".to_string()];
427        assert!(verify_hardware_binding(&binding, &hardware).is_err());
428    }
429
430    #[test]
431    fn test_hostname_binding() {
432        let binding = HardwareBinding::new().with_hostname("my-server");
433
434        let mut hardware = HardwareInfo {
435            hostname: Some("my-server".to_string()),
436            ..Default::default()
437        };
438
439        assert!(verify_hardware_binding(&binding, &hardware).is_ok());
440
441        hardware.hostname = Some("other-server".to_string());
442        assert!(verify_hardware_binding(&binding, &hardware).is_err());
443    }
444}