feat(rust): add device fingerprint and selfhosted provider (cache, license verify, offline validation)

This commit is contained in:
2026-05-18 22:12:49 +08:00
parent f9203e077e
commit 8b90a71077
5 changed files with 631 additions and 0 deletions
@@ -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<i64>,
pub not_after: Option<i64>,
pub offline_grace_days: u32,
pub heartbeat_interval_hours: u32,
pub max_devices: u32,
pub max_concurrent_users: u32,
#[serde(default)]
pub features: HashMap<String, bool>,
}
#[derive(Debug)]
pub struct LicenseCache {
pub license: Option<CachedLicense>,
pub last_checkpoint: i64,
pub cache_dir: PathBuf,
}
#[derive(Serialize, Deserialize)]
struct HeartbeatState {
last_heartbeat: i64,
}
impl LicenseCache {
pub fn load(fp: &DeviceFingerprint) -> Result<Self, LicenseError> {
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::<HeartbeatState>(&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
}
@@ -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<String>,
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<String, bool>,
#[serde(default)]
pub custom: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Grant {
#[serde(rename = "type")]
pub grant_type: String,
pub not_before: Option<i64>,
pub not_after: Option<i64>,
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<LicensePayload, LicenseError> {
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::<Sha256>(), &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");
}
}
@@ -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<SelfHostedConfig>,
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)
}
}