1use 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
14pub const STATE_HMAC_PREFIX: &str = "hmac1:";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct LicenseState {
20 pub last_validated: DateTime<Utc>,
22
23 pub last_system_time: DateTime<Utc>,
25
26 pub validation_count: u64,
28
29 pub license_id_hash: String,
31
32 #[serde(skip)]
34 checksum: Option<String>,
35}
36
37impl LicenseState {
38 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 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 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 pub fn detect_clock_manipulation(&self, tolerance: Duration) -> Result<ClockStatus> {
109 let now = Utc::now();
110
111 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
148pub enum ClockStatus {
149 Ok { current: DateTime<Utc> },
151
152 Backwards {
154 drift: Duration,
155 last_seen: DateTime<Utc>,
156 current: DateTime<Utc>,
157 },
158
159 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct HardwareFingerprint {
176 pub mac_hashes: Vec<String>,
178
179 pub disk_hashes: Vec<String>,
181
182 pub hostname_hash: Option<String>,
184
185 pub machine_guid_hash: Option<String>,
187
188 pub combined_hash: String,
190}
191
192impl HardwareFingerprint {
193 pub fn generate() -> Self {
195 Self::generate_with(&crate::hardware::DefaultHardwareEnvironment)
196 }
197
198 pub fn generate_with(env: &dyn crate::hardware::HardwareEnvironment) -> Self {
200 Self::from_hardware_info(&env.snapshot())
201 }
202
203 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 pub fn match_score(&self, other: &HardwareFingerprint) -> MatchResult {
239 let mut score = 0u32;
240 let mut max_score = 0u32;
241
242 max_score += 2;
244 if self.mac_hashes.iter().any(|h| other.mac_hashes.contains(h)) {
245 score += 2;
246 }
247
248 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 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 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#[derive(Debug, Clone)]
294pub struct MatchResult {
295 pub score: u32,
296 pub max_score: u32,
297 pub percentage: f32,
298}
299
300impl MatchResult {
301 pub fn meets_threshold(&self, threshold_percent: f32) -> bool {
306 self.percentage >= threshold_percent
307 }
308}
309
310fn 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)); }
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 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 assert!(!result.meets_threshold(70.0)); assert!(result.meets_threshold(50.0)); }
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}