mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 18:10:30 +08:00
feat(rust): add device fingerprint and selfhosted provider (cache, license verify, offline validation)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user