Skip to main content

licenz_core/
anti_tamper.rs

1//! Anti-tamper and clock manipulation detection
2//!
3//! This module provides countermeasures against common license bypass techniques.
4
5use crate::error::{LicenseError, Result};
6use chrono::{DateTime, Duration, Utc};
7use hmac::{Hmac, Mac};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::path::Path;
11
12type HmacSha256 = Hmac<Sha256>;
13
14/// Prefix for HMAC-protected state files (see [`LicenseState::save`]).
15pub const STATE_HMAC_PREFIX: &str = "hmac1:";
16
17/// Persistent state for detecting clock manipulation
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct LicenseState {
20    /// Last successful validation timestamp
21    pub last_validated: DateTime<Utc>,
22
23    /// Last observed system time
24    pub last_system_time: DateTime<Utc>,
25
26    /// Monotonic counter (always increases)
27    pub validation_count: u64,
28
29    /// Hash of license ID being tracked
30    pub license_id_hash: String,
31
32    /// State file integrity checksum
33    #[serde(skip)]
34    checksum: Option<String>,
35}
36
37impl LicenseState {
38    /// Create new state for a license
39    pub fn new(license_id: &str) -> Self {
40        let now = Utc::now();
41        Self {
42            last_validated: now,
43            last_system_time: now,
44            validation_count: 1,
45            license_id_hash: hash_string(license_id),
46            checksum: None,
47        }
48    }
49
50    /// Load state from file. The file must use HMAC integrity ([`LicenseState::save`]);
51    /// `key` must match the key used at save time.
52    pub fn load(path: &Path, license_id: &str, key: &[u8; 32]) -> Result<Option<Self>> {
53        if !path.exists() {
54            return Ok(None);
55        }
56
57        let contents = std::fs::read_to_string(path)?;
58
59        let lines: Vec<&str> = contents.lines().collect();
60        if lines.len() < 2 {
61            return Ok(None);
62        }
63
64        let json_data = lines[..lines.len() - 1].join("\n");
65        let stored_line = lines.last().unwrap_or(&"").trim();
66
67        if !stored_line.starts_with(STATE_HMAC_PREFIX) {
68            return Err(LicenseError::Validation(
69                "License state file must use HMAC integrity (hmac1: prefix); re-save with LicenseState::save"
70                    .into(),
71            ));
72        }
73
74        let hex_part = stored_line
75            .strip_prefix(STATE_HMAC_PREFIX)
76            .unwrap_or("")
77            .trim();
78        verify_state_hmac(key, json_data.as_bytes(), hex_part)?;
79
80        let mut state: Self = serde_json::from_str(&json_data)
81            .map_err(|e| LicenseError::InvalidLicenseFormat(e.to_string()))?;
82
83        if state.license_id_hash != hash_string(license_id) {
84            return Err(LicenseError::StateLicenseMismatch);
85        }
86
87        state.checksum = Some(stored_line.to_string());
88        Ok(Some(state))
89    }
90
91    /// Save state with HMAC-SHA256 over the JSON (`key` from secure application storage).
92    pub fn save(&self, path: &Path, key: &[u8; 32]) -> Result<()> {
93        let json_data = serde_json::to_string_pretty(self)
94            .map_err(|e| LicenseError::SerializationError(e.to_string()))?;
95
96        let mac_hex = compute_state_hmac_hex(key, json_data.as_bytes());
97        let contents = format!("{}\n{}{}", json_data, STATE_HMAC_PREFIX, mac_hex);
98
99        if let Some(parent) = path.parent() {
100            std::fs::create_dir_all(parent)?;
101        }
102
103        std::fs::write(path, contents)?;
104        Ok(())
105    }
106
107    /// Check for clock manipulation
108    pub fn detect_clock_manipulation(&self, tolerance: Duration) -> Result<ClockStatus> {
109        let now = Utc::now();
110
111        // Check if clock went backwards significantly
112        if now < self.last_system_time {
113            let drift = self.last_system_time - now;
114
115            if drift > tolerance {
116                return Ok(ClockStatus::Backwards {
117                    drift,
118                    last_seen: self.last_system_time,
119                    current: now,
120                });
121            }
122        }
123
124        // Check for suspicious forward jump (could indicate tampering then reset)
125        let time_since_last = now - self.last_system_time;
126        if time_since_last > Duration::days(365) {
127            return Ok(ClockStatus::SuspiciousJump {
128                jump: time_since_last,
129                last_seen: self.last_system_time,
130                current: now,
131            });
132        }
133
134        Ok(ClockStatus::Ok { current: now })
135    }
136
137    /// Update state after successful validation
138    pub fn record_validation(&mut self) {
139        let now = Utc::now();
140        self.last_validated = now;
141        self.last_system_time = now;
142        self.validation_count += 1;
143    }
144}
145
146/// Result of clock manipulation check
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub enum ClockStatus {
149    /// Clock is within acceptable range
150    Ok { current: DateTime<Utc> },
151
152    /// Clock moved backwards (possible manipulation)
153    Backwards {
154        drift: Duration,
155        last_seen: DateTime<Utc>,
156        current: DateTime<Utc>,
157    },
158
159    /// Clock jumped forward suspiciously far
160    SuspiciousJump {
161        jump: Duration,
162        last_seen: DateTime<Utc>,
163        current: DateTime<Utc>,
164    },
165}
166
167impl ClockStatus {
168    pub fn is_ok(&self) -> bool {
169        matches!(self, ClockStatus::Ok { .. })
170    }
171}
172
173/// Multi-factor hardware fingerprint for stronger binding
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct HardwareFingerprint {
176    /// Hashed MAC addresses
177    pub mac_hashes: Vec<String>,
178
179    /// Hashed disk serial numbers
180    pub disk_hashes: Vec<String>,
181
182    /// Hashed hostname
183    pub hostname_hash: Option<String>,
184
185    /// Hashed machine GUID (OS-level)
186    pub machine_guid_hash: Option<String>,
187
188    /// Combined fingerprint hash
189    pub combined_hash: String,
190}
191
192impl HardwareFingerprint {
193    /// Generate fingerprint using the default OS-visible [`crate::hardware::HardwareEnvironment`].
194    pub fn generate() -> Self {
195        Self::generate_with(&crate::hardware::DefaultHardwareEnvironment)
196    }
197
198    /// Generate fingerprint from a [`crate::hardware::HardwareEnvironment`] snapshot.
199    pub fn generate_with(env: &dyn crate::hardware::HardwareEnvironment) -> Self {
200        Self::from_hardware_info(&env.snapshot())
201    }
202
203    /// Build fingerprint from a [`crate::hardware::HardwareInfo`] snapshot.
204    pub fn from_hardware_info(hw: &crate::hardware::HardwareInfo) -> Self {
205        let mac_hashes: Vec<String> = hw.mac_addresses.iter().map(|m| hash_string(m)).collect();
206
207        let disk_hashes: Vec<String> = hw.disk_ids.iter().map(|d| hash_string(d)).collect();
208
209        let hostname_hash = hw.hostname.as_ref().map(|h| hash_string(h));
210        let machine_guid_hash = hw.machine_id.as_ref().map(|m| hash_string(m));
211
212        let mut combined = String::new();
213        for hash in &mac_hashes {
214            combined.push_str(hash);
215        }
216        for hash in &disk_hashes {
217            combined.push_str(hash);
218        }
219        if let Some(ref h) = hostname_hash {
220            combined.push_str(h);
221        }
222        if let Some(ref m) = machine_guid_hash {
223            combined.push_str(m);
224        }
225
226        let combined_hash = hash_string(&combined);
227
228        Self {
229            mac_hashes,
230            disk_hashes,
231            hostname_hash,
232            machine_guid_hash,
233            combined_hash,
234        }
235    }
236
237    /// Calculate match score against a binding
238    pub fn match_score(&self, other: &HardwareFingerprint) -> MatchResult {
239        let mut score = 0u32;
240        let mut max_score = 0u32;
241
242        // MAC addresses (weight: 2)
243        max_score += 2;
244        if self.mac_hashes.iter().any(|h| other.mac_hashes.contains(h)) {
245            score += 2;
246        }
247
248        // Disk serials (weight: 3) - harder to spoof
249        max_score += 3;
250        if self
251            .disk_hashes
252            .iter()
253            .any(|h| other.disk_hashes.contains(h))
254        {
255            score += 3;
256        }
257
258        // Hostname (weight: 1) - easy to change
259        if self.hostname_hash.is_some() || other.hostname_hash.is_some() {
260            max_score += 1;
261            if self.hostname_hash == other.hostname_hash {
262                score += 1;
263            }
264        }
265
266        // Machine GUID (weight: 4) - OS-level, hardest to spoof
267        if self.machine_guid_hash.is_some() || other.machine_guid_hash.is_some() {
268            max_score += 4;
269            if self.machine_guid_hash == other.machine_guid_hash {
270                score += 4;
271            }
272        }
273
274        let percentage = if max_score > 0 {
275            (score as f32 / max_score as f32) * 100.0
276        } else {
277            100.0
278        };
279
280        MatchResult {
281            score,
282            max_score,
283            percentage,
284        }
285    }
286}
287
288/// Result of hardware fingerprint matching
289///
290/// Note: This struct provides attestation data only. The policy layer
291/// (e.g., `licenz-policy` crate) decides whether the match percentage
292/// is sufficient based on configurable thresholds.
293#[derive(Debug, Clone)]
294pub struct MatchResult {
295    pub score: u32,
296    pub max_score: u32,
297    pub percentage: f32,
298}
299
300impl MatchResult {
301    /// Check if the match meets a given threshold (0.0 - 100.0)
302    ///
303    /// This is a convenience method. Policy enforcement should use
304    /// the `percentage` field directly with configurable thresholds.
305    pub fn meets_threshold(&self, threshold_percent: f32) -> bool {
306        self.percentage >= threshold_percent
307    }
308}
309
310/// Hash a string with SHA-256 and return hex
311fn hash_string(input: &str) -> String {
312    let mut hasher = Sha256::new();
313    hasher.update(input.as_bytes());
314    hex::encode(hasher.finalize())
315}
316
317fn compute_state_hmac_hex(key: &[u8; 32], data: &[u8]) -> String {
318    let mut mac = HmacSha256::new_from_slice(key.as_slice()).expect("HMAC accepts 32-byte key");
319    mac.update(data);
320    hex::encode(mac.finalize().into_bytes())
321}
322
323fn verify_state_hmac(key: &[u8; 32], data: &[u8], expected_hex: &str) -> Result<()> {
324    let expected_bin = hex::decode(expected_hex).map_err(|_| LicenseError::StateFileTampered)?;
325    let mut mac = HmacSha256::new_from_slice(key.as_slice()).expect("HMAC accepts 32-byte key");
326    mac.update(data);
327    mac.verify_slice(&expected_bin)
328        .map_err(|_| LicenseError::StateFileTampered)
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_clock_status_ok() {
337        let state = LicenseState::new("test-license");
338        let status = state.detect_clock_manipulation(Duration::hours(1)).unwrap();
339        assert!(status.is_ok());
340    }
341
342    #[test]
343    fn test_fingerprint_match() {
344        let fp1 = HardwareFingerprint {
345            mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
346            disk_hashes: vec![hash_string("DISK123")],
347            hostname_hash: Some(hash_string("myhost")),
348            machine_guid_hash: Some(hash_string("guid123")),
349            combined_hash: String::new(),
350        };
351
352        let fp2 = HardwareFingerprint {
353            mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
354            disk_hashes: vec![hash_string("DISK123")],
355            hostname_hash: Some(hash_string("myhost")),
356            machine_guid_hash: Some(hash_string("guid123")),
357            combined_hash: String::new(),
358        };
359
360        let result = fp1.match_score(&fp2);
361        assert_eq!(result.percentage, 100.0);
362        assert!(result.meets_threshold(70.0)); // Policy decision made by caller
363    }
364
365    #[test]
366    fn test_partial_fingerprint_match() {
367        let fp1 = HardwareFingerprint {
368            mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
369            disk_hashes: vec![hash_string("DISK123")],
370            hostname_hash: Some(hash_string("myhost")),
371            machine_guid_hash: Some(hash_string("guid123")),
372            combined_hash: String::new(),
373        };
374
375        // Different hostname and GUID, same MAC and disk
376        let fp2 = HardwareFingerprint {
377            mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
378            disk_hashes: vec![hash_string("DISK123")],
379            hostname_hash: Some(hash_string("otherhost")),
380            machine_guid_hash: Some(hash_string("otherguid")),
381            combined_hash: String::new(),
382        };
383
384        let result = fp1.match_score(&fp2);
385        // MAC (2) + Disk (3) = 5 out of 10 = 50%
386        // Whether this passes depends on the policy threshold chosen by caller
387        assert!(!result.meets_threshold(70.0)); // Would fail default 70% threshold
388        assert!(result.meets_threshold(50.0)); // Would pass permissive 50% threshold
389    }
390
391    #[test]
392    fn state_hmac_roundtrip() {
393        use tempfile::TempDir;
394
395        let dir = TempDir::new().unwrap();
396        let path = dir.path().join("state.json");
397        let key = [7u8; 32];
398        let state = LicenseState::new("lic-1");
399        state.save(&path, &key).unwrap();
400        let loaded = LicenseState::load(&path, "lic-1", &key).unwrap().unwrap();
401        assert_eq!(loaded.license_id_hash, state.license_id_hash);
402    }
403
404    #[test]
405    fn state_rejects_non_hmac_file() {
406        use tempfile::TempDir;
407
408        let dir = TempDir::new().unwrap();
409        let path = dir.path().join("state.json");
410        let key = [1u8; 32];
411        std::fs::write(&path, "{}\nnot_hmac_prefix").unwrap();
412        let err = LicenseState::load(&path, "lic-2", &key).unwrap_err();
413        assert!(matches!(err, LicenseError::Validation(_)));
414    }
415}