From 8b90a71077244f252d7038c44931b8f513c655b5 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 18 May 2026 22:12:49 +0800 Subject: [PATCH] feat(rust): add device fingerprint and selfhosted provider (cache, license verify, offline validation) --- native/craft-core/src/device.rs | 154 ++++++++++++ native/craft-core/src/lib.rs | 2 + .../src/provider_selfhosted/cache.rs | 106 ++++++++ .../src/provider_selfhosted/license.rs | 236 ++++++++++++++++++ .../craft-core/src/provider_selfhosted/mod.rs | 133 ++++++++++ 5 files changed, 631 insertions(+) create mode 100644 native/craft-core/src/device.rs create mode 100644 native/craft-core/src/provider_selfhosted/cache.rs create mode 100644 native/craft-core/src/provider_selfhosted/license.rs create mode 100644 native/craft-core/src/provider_selfhosted/mod.rs diff --git a/native/craft-core/src/device.rs b/native/craft-core/src/device.rs new file mode 100644 index 0000000..d0599bc --- /dev/null +++ b/native/craft-core/src/device.rs @@ -0,0 +1,154 @@ +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone)] +pub struct FingerprintLayer { + pub source: &'static str, + pub value: Option, +} + +#[derive(Debug, Clone)] +pub struct DeviceFingerprint { + pub composite_hash: String, + pub stability_score: u8, + pub layers: Vec, +} + +pub fn collect() -> DeviceFingerprint { + let mut layers = Vec::new(); + + let l1 = try_dmi_uuid(); + layers.push(FingerprintLayer { source: "hw_uuid", value: l1 }); + + let l2 = try_machine_id(); + layers.push(FingerprintLayer { source: "os_id", value: l2 }); + + let l3 = try_rootfs_uuid(); + layers.push(FingerprintLayer { source: "fs_uuid", value: l3 }); + + let l4 = Some(try_physical_mac()); + layers.push(FingerprintLayer { source: "mac", value: l4 }); + + let mut hasher = Sha256::new(); + for layer in &layers { + if let Some(ref v) = layer.value { + hasher.update(v.as_bytes()); + } + hasher.update(b"|"); + } + let hash = format!("HC-{:x}", hasher.finalize()); + + let stability = compute_stability(&layers); + + DeviceFingerprint { + composite_hash: hash, + stability_score: stability, + layers, + } +} + +fn compute_stability(layers: &[FingerprintLayer]) -> u8 { + let mut score: u8 = 0; + if layers[0].value.is_some() { score += 40; } + if layers[1].value.is_some() { score += 30; } + if layers[2].value.is_some() { score += 20; } + if layers[3].value.is_some() { score += 10; } + score +} + +#[cfg(target_os = "linux")] +fn try_dmi_uuid() -> Option { + std::fs::read_to_string("/sys/class/dmi/id/product_uuid") + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty() && s != "00000000-0000-0000-0000-000000000000") +} + +#[cfg(not(target_os = "linux"))] +fn try_dmi_uuid() -> Option { + None +} + +#[cfg(target_os = "linux")] +fn try_machine_id() -> Option { + std::fs::read_to_string("/etc/machine-id") + .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id")) + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty()) +} + +#[cfg(not(target_os = "linux"))] +fn try_machine_id() -> Option { + None +} + +#[cfg(target_os = "linux")] +fn try_rootfs_uuid() -> Option { + std::fs::read_to_string("/proc/cmdline") + .ok() + .and_then(|cmdline| { + for part in cmdline.split_whitespace() { + if part.starts_with("root=") { + return Some(part.trim_start_matches("root=").to_string()); + } + } + None + }) +} + +#[cfg(not(target_os = "linux"))] +fn try_rootfs_uuid() -> Option { + None +} + +fn try_physical_mac() -> String { + #[cfg(target_os = "linux")] + { + let mut macs: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir("/sys/class/net") { + for entry in entries.flatten() { + let iface = entry.file_name().to_string_lossy().to_string(); + if iface == "lo" || iface.starts_with("docker") || iface.starts_with("veth") || iface.starts_with("tap") { + continue; + } + if let Ok(addr) = std::fs::read_to_string(entry.path().join("address")) { + let a = addr.trim().to_string(); + if !a.is_empty() && a != "00:00:00:00:00:00" { + macs.push(a); + } + } + } + } + if macs.is_empty() { "unknown-mac".to_string() } else { macs.sort(); macs.join(",") } + } + #[cfg(not(target_os = "linux"))] + { "unknown-mac".to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_collect_returns_valid_structure() { + let fp = collect(); + assert!(fp.composite_hash.starts_with("HC-")); + assert_eq!(fp.layers.len(), 4); + for layer in &fp.layers { + assert!(matches!(layer.source, "hw_uuid" | "os_id" | "fs_uuid" | "mac")); + } + } + + #[test] + fn test_stability_score_never_exceeds_100() { + let fp = collect(); + assert!(fp.stability_score <= 100); + } + + #[test] + fn test_composite_hash_deterministic() { + let fp1 = collect(); + let fp2 = collect(); + assert_eq!(fp1.composite_hash, fp2.composite_hash); + } +} diff --git a/native/craft-core/src/lib.rs b/native/craft-core/src/lib.rs index c2ec080..430a8d0 100644 --- a/native/craft-core/src/lib.rs +++ b/native/craft-core/src/lib.rs @@ -13,6 +13,8 @@ mod security; mod error; mod session; pub mod crypto; +pub mod device; +pub mod provider_selfhosted; pub struct CraftContext { dummy: i32, diff --git a/native/craft-core/src/provider_selfhosted/cache.rs b/native/craft-core/src/provider_selfhosted/cache.rs new file mode 100644 index 0000000..a4e2ff7 --- /dev/null +++ b/native/craft-core/src/provider_selfhosted/cache.rs @@ -0,0 +1,106 @@ +use crate::crypto; +use crate::device::DeviceFingerprint; +use crate::error::LicenseError; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedLicense { + pub license_id: String, + pub not_before: Option, + pub not_after: Option, + pub offline_grace_days: u32, + pub heartbeat_interval_hours: u32, + pub max_devices: u32, + pub max_concurrent_users: u32, + #[serde(default)] + pub features: HashMap, +} + +#[derive(Debug)] +pub struct LicenseCache { + pub license: Option, + pub last_checkpoint: i64, + pub cache_dir: PathBuf, +} + +#[derive(Serialize, Deserialize)] +struct HeartbeatState { + last_heartbeat: i64, +} + +impl LicenseCache { + pub fn load(fp: &DeviceFingerprint) -> Result { + let cache_dir = craftlabs_dir(); + let cache_file = cache_dir.join("license_cache.json"); + + let license = if cache_file.exists() { + let encrypted = std::fs::read(&cache_file) + .map_err(|_| LicenseError::ConfigMissing("cannot read cache file"))?; + let aes_key = cache_encryption_key(fp); + let plain = crypto::aes_gcm_decrypt(&aes_key, &encrypted)?; + let cached: CachedLicense = serde_json::from_slice(&plain) + .map_err(|_| LicenseError::CorruptedPayload)?; + Some(cached) + } else { + None + }; + + let checkpoint_file = cache_dir.join("heartbeat_state.json"); + let last_checkpoint = if checkpoint_file.exists() { + let content = std::fs::read_to_string(&checkpoint_file).unwrap_or_default(); + serde_json::from_str::(&content) + .map(|h| h.last_heartbeat) + .unwrap_or(0) + } else { + 0 + }; + + Ok(LicenseCache { license, last_checkpoint, cache_dir }) + } + + pub fn store(&mut self, cached: CachedLicense) -> Result<(), LicenseError> { + self.license = Some(cached); + Ok(()) + } + + pub fn update_checkpoint(&mut self) { + use std::time::{SystemTime, UNIX_EPOCH}; + self.last_checkpoint = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + } + + pub fn persist(&self, fp: &DeviceFingerprint) -> Result<(), LicenseError> { + if let Some(ref cached) = self.license { + let json = serde_json::to_vec(cached).unwrap(); + let aes_key = cache_encryption_key(fp); + let encrypted = crypto::aes_gcm_encrypt(&aes_key, &json); + let f = self.cache_dir.join("license_cache.json"); + std::fs::write(&f, encrypted) + .map_err(|_| LicenseError::ConfigMissing("cannot write cache"))?; + } + + let state = HeartbeatState { last_heartbeat: self.last_checkpoint }; + let json = serde_json::to_string(&state).unwrap(); + let f = self.cache_dir.join("heartbeat_state.json"); + std::fs::write(&f, json).ok(); + + Ok(()) + } +} + +fn cache_encryption_key(fp: &DeviceFingerprint) -> [u8; 32] { + let salt = format!("cache-{}", fp.composite_hash); + crypto::derive_aes_key(&salt) +} + +fn craftlabs_dir() -> PathBuf { + let home = std::env::var("HOME").ok().map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let dir = home.join(".craftlabs"); + let _ = std::fs::create_dir_all(&dir); + dir +} diff --git a/native/craft-core/src/provider_selfhosted/license.rs b/native/craft-core/src/provider_selfhosted/license.rs new file mode 100644 index 0000000..62edc46 --- /dev/null +++ b/native/craft-core/src/provider_selfhosted/license.rs @@ -0,0 +1,236 @@ +use crate::crypto; +use crate::error::LicenseError; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Deserialize)] +pub struct RawLicense { + pub version: i32, + pub license_id: String, + #[serde(default)] + pub issued_at: Option, + pub payload: String, + pub signature: SignatureBlock, +} + +#[derive(Deserialize)] +pub struct SignatureBlock { + pub algorithm: String, + pub key_id: String, + pub value: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LicensePayload { + pub tenant_id: String, + pub product: String, + pub grant: Grant, + pub constraints: Constraints, + #[serde(default)] + pub features: HashMap, + #[serde(default)] + pub custom: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Grant { + #[serde(rename = "type")] + pub grant_type: String, + pub not_before: Option, + pub not_after: Option, + pub offline_grace_days: u32, + pub heartbeat_interval_hours: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Constraints { + pub max_devices: u32, + #[serde(default)] + pub max_concurrent_users: u32, + #[serde(default)] + pub max_activations: u32, +} + +pub fn verify_and_parse( + license_json: &str, + public_key_pem: &str, +) -> Result { + let raw: RawLicense = serde_json::from_str(license_json) + .map_err(|_| LicenseError::InvalidFormat("license json"))?; + + if raw.version != 1 { + return Err(LicenseError::InvalidFormat("unsupported license version")); + } + + let payload_bytes = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + &raw.payload, + ) + .map_err(|_| LicenseError::InvalidFormat("payload base64"))?; + + crypto::verify_rsa_signature(public_key_pem, &payload_bytes, &raw.signature.value)?; + + let aes_key = crypto::derive_aes_key(&raw.license_id); + let plain = crypto::aes_gcm_decrypt(&aes_key, &payload_bytes)?; + + let license: LicensePayload = serde_json::from_slice(&plain) + .map_err(|_| LicenseError::CorruptedPayload)?; + + Ok(license) +} + +pub fn validate_time_window( + license: &LicensePayload, + offline_grace_days: u32, + last_checkpoint: i64, +) -> Result<(), LicenseError> { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + if let Some(not_before) = license.grant.not_before { + if now < not_before { + return Err(LicenseError::NotYetValid); + } + } + + if let Some(not_after) = license.grant.not_after { + let effective_deadline = not_after + (offline_grace_days as i64 * 86400); + if now > effective_deadline { + return Err(LicenseError::Expired); + } + } + + if last_checkpoint > 0 { + let days_offline = ((now - last_checkpoint) / 86400) as u32; + if days_offline > offline_grace_days { + return Err(LicenseError::OfflineGraceExceeded { + days_offline, + max_days: offline_grace_days, + }); + } + } + + Ok(()) +} + +pub fn to_cached_license( + payload: &LicensePayload, + offline_grace_days: u32, + heartbeat_interval_hours: u32, +) -> super::cache::CachedLicense { + super::cache::CachedLicense { + license_id: payload.tenant_id.clone() + "-" + &payload.product, + not_before: payload.grant.not_before, + not_after: payload.grant.not_after, + offline_grace_days, + heartbeat_interval_hours, + max_devices: payload.constraints.max_devices, + max_concurrent_users: payload.constraints.max_concurrent_users, + features: payload.features.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto; + + #[test] + fn test_validate_time_window_expired() { + let lp = LicensePayload { + tenant_id: "test".into(), + product: "test".into(), + grant: Grant { + grant_type: "subscription".into(), + not_before: Some(0), + not_after: Some(100), + offline_grace_days: 0, + heartbeat_interval_hours: 24, + }, + constraints: Constraints { max_devices: 5, max_concurrent_users: 0, max_activations: 0 }, + features: HashMap::new(), + custom: HashMap::new(), + }; + assert!(validate_time_window(&lp, 7, 0).is_err()); + } + + #[test] + fn test_validate_time_window_valid() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + let lp = LicensePayload { + tenant_id: "test".into(), + product: "test".into(), + grant: Grant { + grant_type: "perpetual".into(), + not_before: Some(now - 86400), + not_after: Some(now + 86400 * 365), + offline_grace_days: 7, + heartbeat_interval_hours: 24, + }, + constraints: Constraints { max_devices: 5, max_concurrent_users: 0, max_activations: 0 }, + features: HashMap::new(), + custom: HashMap::new(), + }; + assert!(validate_time_window(&lp, 7, now).is_ok()); + } + + #[test] + fn test_verify_and_parse_with_generated_license() { + use rsa::pkcs8::EncodePublicKey; + use rsa::signature::Signer; + use rsa::{pkcs8::LineEnding, Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey}; + use sha2::{Digest, Sha256}; + use base64::Engine; + + let mut rng = rand::rngs::OsRng; + let priv_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let pub_key = RsaPublicKey::from(&priv_key); + let pub_pem = pub_key.to_public_key_pem(LineEnding::LF).unwrap(); + + let payload = LicensePayload { + tenant_id: "test-tenant".into(), + product: "test-product".into(), + grant: Grant { + grant_type: "perpetual".into(), + not_before: Some(0), + not_after: Some(9999999999), + offline_grace_days: 7, + heartbeat_interval_hours: 24, + }, + constraints: Constraints { max_devices: 5, max_concurrent_users: 0, max_activations: 0 }, + features: [("api".into(), true)].into(), + custom: HashMap::new(), + }; + + let payload_json = serde_json::to_vec(&payload).unwrap(); + let aes_key = crypto::derive_aes_key("test-license-id"); + let encrypted = crypto::aes_gcm_encrypt(&aes_key, &payload_json); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&encrypted); + + let mut hasher = Sha256::new(); + hasher.update(&encrypted); + let digest = hasher.finalize(); + let sig = priv_key.sign(Pkcs1v15Sign::new::(), &digest).unwrap(); + let sig_bytes: &[u8] = sig.as_ref(); + let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig_bytes); + + let license_json = serde_json::json!({ + "version": 1, + "license_id": "test-license-id", + "payload": payload_b64, + "signature": { + "algorithm": "RS256", + "key_id": "test-key", + "value": sig_b64 + } + }).to_string(); + + let result = verify_and_parse(&license_json, &pub_pem); + assert!(result.is_ok(), "{:?}", result.err()); + assert_eq!(result.unwrap().tenant_id, "test-tenant"); + } +} diff --git a/native/craft-core/src/provider_selfhosted/mod.rs b/native/craft-core/src/provider_selfhosted/mod.rs new file mode 100644 index 0000000..ce7c4c3 --- /dev/null +++ b/native/craft-core/src/provider_selfhosted/mod.rs @@ -0,0 +1,133 @@ +pub mod cache; +pub mod license; + +use std::collections::HashMap; +use crate::{ + CraftContext, LicenseInfo, + device::DeviceFingerprint, + error::LicenseError, +}; + +pub struct SelfHostedConfig { + pub base_url: String, + pub tenant_key: String, + pub offline_grace_days: u32, + pub heartbeat_interval_hours: u32, + pub public_key_pem: String, +} + +pub struct SelfHostedProvider { + config: Option, + pub cache: cache::LicenseCache, + pub device_fp: DeviceFingerprint, +} + +impl SelfHostedProvider { + pub fn new() -> Self { + let fp = crate::device::collect(); + let cache = cache::LicenseCache::load(&fp) + .unwrap_or_else(|_| cache::LicenseCache { + license: None, + last_checkpoint: 0, + cache_dir: std::path::PathBuf::new(), + }); + + Self { config: None, cache, device_fp: fp } + } + + pub fn initialize( + &mut self, + base_url: String, + tenant_key: String, + offline_grace_days: u32, + heartbeat_interval_hours: u32, + public_key_pem: String, + ) -> Result<(), LicenseError> { + self.config = Some(SelfHostedConfig { + base_url, + tenant_key, + offline_grace_days, + heartbeat_interval_hours, + public_key_pem, + }); + Ok(()) + } + + pub fn check_license_offline(&self) -> Result<(), LicenseError> { + let cached = self.cache.license.as_ref() + .ok_or(LicenseError::NoCachedLicense)?; + + license::validate_time_window( + &license::LicensePayload { + tenant_id: String::new(), + product: String::new(), + grant: license::Grant { + grant_type: "cached".into(), + not_before: cached.not_before, + not_after: cached.not_after, + offline_grace_days: cached.offline_grace_days, + heartbeat_interval_hours: cached.heartbeat_interval_hours, + }, + constraints: license::Constraints { + max_devices: cached.max_devices, + max_concurrent_users: cached.max_concurrent_users, + max_activations: 0, + }, + features: cached.features.clone(), + custom: HashMap::new(), + }, + cached.offline_grace_days, + self.cache.last_checkpoint, + )?; + + Ok(()) + } + + pub fn get_license_info_offline(&self) -> LicenseInfo { + if let Some(ref _cached) = self.cache.license { + LicenseInfo { + is_licensed: 1, + expiration_date: 0, + feature_names: std::ptr::null(), + feature_values: std::ptr::null(), + feature_count: 0, + } + } else { + LicenseInfo { + is_licensed: 0, + expiration_date: 0, + feature_names: std::ptr::null(), + feature_values: std::ptr::null(), + feature_count: 0, + } + } + } + + pub fn has_feature_offline(&self, name: &str) -> bool { + self.cache.license.as_ref() + .and_then(|c| c.features.get(name)) + .copied() + .unwrap_or(false) + } + + pub fn verify_and_cache_license( + &mut self, + license_json: &str, + ) -> Result<(), LicenseError> { + let cfg = self.config.as_ref().ok_or(LicenseError::NotInitialized)?; + let payload = license::verify_and_parse(license_json, &cfg.public_key_pem)?; + + let cached = license::to_cached_license( + &payload, + cfg.offline_grace_days, + cfg.heartbeat_interval_hours, + ); + self.cache.store(cached)?; + self.cache.update_checkpoint(); + Ok(()) + } + + pub fn persist_cache(&self) -> Result<(), LicenseError> { + self.cache.persist(&self.device_fp) + } +}