use crate::error::{LicenseError, Result};
use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
type HmacSha256 = Hmac<Sha256>;
pub const STATE_HMAC_PREFIX: &str = "hmac1:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseState {
pub last_validated: DateTime<Utc>,
pub last_system_time: DateTime<Utc>,
pub validation_count: u64,
pub license_id_hash: String,
#[serde(skip)]
checksum: Option<String>,
}
impl LicenseState {
pub fn new(license_id: &str) -> Self {
let now = Utc::now();
Self {
last_validated: now,
last_system_time: now,
validation_count: 1,
license_id_hash: hash_string(license_id),
checksum: None,
}
}
pub fn load(path: &Path, license_id: &str, key: &[u8; 32]) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
let lines: Vec<&str> = contents.lines().collect();
if lines.len() < 2 {
return Ok(None);
}
let json_data = lines[..lines.len() - 1].join("\n");
let stored_line = lines.last().unwrap_or(&"").trim();
if !stored_line.starts_with(STATE_HMAC_PREFIX) {
return Err(LicenseError::Validation(
"License state file must use HMAC integrity (hmac1: prefix); re-save with LicenseState::save"
.into(),
));
}
let hex_part = stored_line
.strip_prefix(STATE_HMAC_PREFIX)
.unwrap_or("")
.trim();
verify_state_hmac(key, json_data.as_bytes(), hex_part)?;
let mut state: Self = serde_json::from_str(&json_data)
.map_err(|e| LicenseError::InvalidLicenseFormat(e.to_string()))?;
if state.license_id_hash != hash_string(license_id) {
return Err(LicenseError::StateLicenseMismatch);
}
state.checksum = Some(stored_line.to_string());
Ok(Some(state))
}
pub fn save(&self, path: &Path, key: &[u8; 32]) -> Result<()> {
let json_data = serde_json::to_string_pretty(self)
.map_err(|e| LicenseError::SerializationError(e.to_string()))?;
let mac_hex = compute_state_hmac_hex(key, json_data.as_bytes());
let contents = format!("{}\n{}{}", json_data, STATE_HMAC_PREFIX, mac_hex);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, contents)?;
Ok(())
}
pub fn detect_clock_manipulation(&self, tolerance: Duration) -> Result<ClockStatus> {
let now = Utc::now();
if now < self.last_system_time {
let drift = self.last_system_time - now;
if drift > tolerance {
return Ok(ClockStatus::Backwards {
drift,
last_seen: self.last_system_time,
current: now,
});
}
}
let time_since_last = now - self.last_system_time;
if time_since_last > Duration::days(365) {
return Ok(ClockStatus::SuspiciousJump {
jump: time_since_last,
last_seen: self.last_system_time,
current: now,
});
}
Ok(ClockStatus::Ok { current: now })
}
pub fn record_validation(&mut self) {
let now = Utc::now();
self.last_validated = now;
self.last_system_time = now;
self.validation_count += 1;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClockStatus {
Ok { current: DateTime<Utc> },
Backwards {
drift: Duration,
last_seen: DateTime<Utc>,
current: DateTime<Utc>,
},
SuspiciousJump {
jump: Duration,
last_seen: DateTime<Utc>,
current: DateTime<Utc>,
},
}
impl ClockStatus {
pub fn is_ok(&self) -> bool {
matches!(self, ClockStatus::Ok { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HardwareFingerprint {
pub mac_hashes: Vec<String>,
pub disk_hashes: Vec<String>,
pub hostname_hash: Option<String>,
pub machine_guid_hash: Option<String>,
pub combined_hash: String,
}
impl HardwareFingerprint {
pub fn generate() -> Self {
Self::generate_with(&crate::hardware::DefaultHardwareEnvironment)
}
pub fn generate_with(env: &dyn crate::hardware::HardwareEnvironment) -> Self {
Self::from_hardware_info(&env.snapshot())
}
pub fn from_hardware_info(hw: &crate::hardware::HardwareInfo) -> Self {
let mac_hashes: Vec<String> = hw.mac_addresses.iter().map(|m| hash_string(m)).collect();
let disk_hashes: Vec<String> = hw.disk_ids.iter().map(|d| hash_string(d)).collect();
let hostname_hash = hw.hostname.as_ref().map(|h| hash_string(h));
let machine_guid_hash = hw.machine_id.as_ref().map(|m| hash_string(m));
let mut combined = String::new();
for hash in &mac_hashes {
combined.push_str(hash);
}
for hash in &disk_hashes {
combined.push_str(hash);
}
if let Some(ref h) = hostname_hash {
combined.push_str(h);
}
if let Some(ref m) = machine_guid_hash {
combined.push_str(m);
}
let combined_hash = hash_string(&combined);
Self {
mac_hashes,
disk_hashes,
hostname_hash,
machine_guid_hash,
combined_hash,
}
}
pub fn match_score(&self, other: &HardwareFingerprint) -> MatchResult {
let mut score = 0u32;
let mut max_score = 0u32;
max_score += 2;
if self.mac_hashes.iter().any(|h| other.mac_hashes.contains(h)) {
score += 2;
}
max_score += 3;
if self
.disk_hashes
.iter()
.any(|h| other.disk_hashes.contains(h))
{
score += 3;
}
if self.hostname_hash.is_some() || other.hostname_hash.is_some() {
max_score += 1;
if self.hostname_hash == other.hostname_hash {
score += 1;
}
}
if self.machine_guid_hash.is_some() || other.machine_guid_hash.is_some() {
max_score += 4;
if self.machine_guid_hash == other.machine_guid_hash {
score += 4;
}
}
let percentage = if max_score > 0 {
(score as f32 / max_score as f32) * 100.0
} else {
100.0
};
MatchResult {
score,
max_score,
percentage,
}
}
}
#[derive(Debug, Clone)]
pub struct MatchResult {
pub score: u32,
pub max_score: u32,
pub percentage: f32,
}
impl MatchResult {
pub fn meets_threshold(&self, threshold_percent: f32) -> bool {
self.percentage >= threshold_percent
}
}
fn hash_string(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
hex::encode(hasher.finalize())
}
fn compute_state_hmac_hex(key: &[u8; 32], data: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(key.as_slice()).expect("HMAC accepts 32-byte key");
mac.update(data);
hex::encode(mac.finalize().into_bytes())
}
fn verify_state_hmac(key: &[u8; 32], data: &[u8], expected_hex: &str) -> Result<()> {
let expected_bin = hex::decode(expected_hex).map_err(|_| LicenseError::StateFileTampered)?;
let mut mac = HmacSha256::new_from_slice(key.as_slice()).expect("HMAC accepts 32-byte key");
mac.update(data);
mac.verify_slice(&expected_bin)
.map_err(|_| LicenseError::StateFileTampered)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clock_status_ok() {
let state = LicenseState::new("test-license");
let status = state.detect_clock_manipulation(Duration::hours(1)).unwrap();
assert!(status.is_ok());
}
#[test]
fn test_fingerprint_match() {
let fp1 = HardwareFingerprint {
mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
disk_hashes: vec![hash_string("DISK123")],
hostname_hash: Some(hash_string("myhost")),
machine_guid_hash: Some(hash_string("guid123")),
combined_hash: String::new(),
};
let fp2 = HardwareFingerprint {
mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
disk_hashes: vec![hash_string("DISK123")],
hostname_hash: Some(hash_string("myhost")),
machine_guid_hash: Some(hash_string("guid123")),
combined_hash: String::new(),
};
let result = fp1.match_score(&fp2);
assert_eq!(result.percentage, 100.0);
assert!(result.meets_threshold(70.0)); }
#[test]
fn test_partial_fingerprint_match() {
let fp1 = HardwareFingerprint {
mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
disk_hashes: vec![hash_string("DISK123")],
hostname_hash: Some(hash_string("myhost")),
machine_guid_hash: Some(hash_string("guid123")),
combined_hash: String::new(),
};
let fp2 = HardwareFingerprint {
mac_hashes: vec![hash_string("AA:BB:CC:DD:EE:FF")],
disk_hashes: vec![hash_string("DISK123")],
hostname_hash: Some(hash_string("otherhost")),
machine_guid_hash: Some(hash_string("otherguid")),
combined_hash: String::new(),
};
let result = fp1.match_score(&fp2);
assert!(!result.meets_threshold(70.0)); assert!(result.meets_threshold(50.0)); }
#[test]
fn state_hmac_roundtrip() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.json");
let key = [7u8; 32];
let state = LicenseState::new("lic-1");
state.save(&path, &key).unwrap();
let loaded = LicenseState::load(&path, "lic-1", &key).unwrap().unwrap();
assert_eq!(loaded.license_id_hash, state.license_id_hash);
}
#[test]
fn state_rejects_non_hmac_file() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.json");
let key = [1u8; 32];
std::fs::write(&path, "{}\nnot_hmac_prefix").unwrap();
let err = LicenseState::load(&path, "lic-2", &key).unwrap_err();
assert!(matches!(err, LicenseError::Validation(_)));
}
}