feat(license): add gyandoot-license crate + CLI subcommand

- New crate: gyandoot-license with:
  - Machine fingerprinting (CPU, MAC, Disk, hostname)
  - Ed25519 JWT signing and verification
  - Agent counting with RAII guards
  - AES-256-GCM encrypted license cache
  - Anti-tamper binary integrity checks
  - Debugger detection
- Added to workspace Cargo.toml
- Added 'gyandoot license' CLI subcommand (activate, status, deactivate, fingerprint)
This commit is contained in:
2026-07-05 10:14:48 +05:30
parent d5cab65099
commit 968710dc46
9 changed files with 973 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
[package]
name = "gyandoot-license"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "GyanDoot License Validation — JWT, Machine Binding, Agent Counting"
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tracing.workspace = true
sha2.workspace = true
hex.workspace = true
base64.workspace = true
rand.workspace = true
chrono.workspace = true
uuid.workspace = true
reqwest.workspace = true
aes-gcm.workspace = true
# JWT
jsonwebtoken = "9"
ed25519-dalek = "2"
# Machine fingerprint
machine-uid = "0.3"

View File

@@ -0,0 +1,100 @@
//! Agent Counter
//!
//! Tracks and enforces the maximum number of concurrent agents
//! allowed by the license.
use crate::LicenseError;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
/// Tracks active agent count and enforces license limits
pub struct AgentCounter {
max_agents: u32,
active_agents: Arc<AtomicU32>,
}
impl AgentCounter {
/// Create a new agent counter with a license limit
pub fn new(max_agents: u32) -> Self {
Self {
max_agents,
active_agents: Arc::new(AtomicU32::new(0)),
}
}
/// Try to acquire an agent slot
pub fn acquire(&self) -> Result<AgentGuard, LicenseError> {
let current = self.active_agents.fetch_add(1, Ordering::SeqCst);
if current >= self.max_agents {
self.active_agents.fetch_sub(1, Ordering::SeqCst);
return Err(LicenseError::MaxAgentsExceeded {
max: self.max_agents,
current,
});
}
Ok(AgentGuard {
counter: self.active_agents.clone(),
})
}
/// Get current active agent count
pub fn active_count(&self) -> u32 {
self.active_agents.load(Ordering::SeqCst)
}
/// Get max allowed agents
pub fn max_agents(&self) -> u32 {
self.max_agents
}
/// Check if we can spawn more agents
pub fn can_spawn(&self) -> bool {
self.active_agents.load(Ordering::SeqCst) < self.max_agents
}
}
/// RAII guard that decrements agent count on drop
pub struct AgentGuard {
counter: Arc<AtomicU32>,
}
impl Drop for AgentGuard {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_counting() {
let counter = AgentCounter::new(3);
assert_eq!(counter.active_count(), 0);
assert!(counter.can_spawn());
let _g1 = counter.acquire().unwrap();
assert_eq!(counter.active_count(), 1);
let _g2 = counter.acquire().unwrap();
let _g3 = counter.acquire().unwrap();
assert_eq!(counter.active_count(), 3);
assert!(!counter.can_spawn());
// 4th should fail
let result = counter.acquire();
assert!(result.is_err());
}
#[test]
fn test_agent_guard_drop() {
let counter = AgentCounter::new(2);
{
let _g1 = counter.acquire().unwrap();
assert_eq!(counter.active_count(), 1);
}
// After drop, count should decrease
assert_eq!(counter.active_count(), 0);
}
}

View File

@@ -0,0 +1,118 @@
//! Anti-Tamper & Debugger Detection
//!
//! Prevents the binary from being modified or debugged.
//! - Binary integrity hash verification
//! - Debugger detection (ptrace)
//! - String obfuscation helpers
use crate::LicenseError;
use sha2::{Sha256, Digest};
/// Compute SHA-256 hash of the current binary
pub fn current_binary_hash() -> String {
let exe_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("unknown"));
if let Ok(data) = std::fs::read(&exe_path) {
let mut hasher = Sha256::new();
hasher.update(&data);
hex::encode(hasher.finalize())
} else {
"unknown_binary".to_string()
}
}
/// Verify binary integrity against an expected hash
pub fn verify_binary(expected_hash: &str) -> Result<(), LicenseError> {
let actual = current_binary_hash();
if actual != expected_hash {
return Err(LicenseError::BinaryTampered);
}
Ok(())
}
/// Detect if a debugger is attached
pub fn detect_debugger() -> bool {
// Linux: check if being ptraced
#[cfg(target_os = "linux")]
{
// Try ptrace(PTRACE_TRACEME, ...) — if it fails, we're being debugged
let result = unsafe { libc::ptrace(libc::PTRACE_TRACEME, 0, 0, 0) };
if result == -1 {
return true; // Being traced
}
// Detach
unsafe { libc::ptrace(libc::PTRACE_DETACH, 0, 0, 0) };
}
// Check for common debugger environment variables
let debug_env_vars = [
"DEBUG", "DEBUGGER", "JAVA_DEBUG", "PYTHONDEBUG",
"TRACE", "TRACEME", "LD_PRELOAD",
];
for var in &debug_env_vars {
if std::env::var(var).is_ok() {
return true;
}
}
// Check for /proc/self/status TracerPid
#[cfg(target_os = "linux")]
{
if let Ok(content) = std::fs::read_to_string("/proc/self/status") {
for line in content.lines() {
if line.starts_with("TracerPid:") {
let pid = line.trim_start_matches("TracerPid:").trim();
if pid != "0" {
return true;
}
}
}
}
}
false
}
/// Run all anti-tamper checks at startup
pub fn run_startup_checks(expected_binary_hash: &str) -> Result<(), LicenseError> {
// Check 1: Debugger
if detect_debugger() {
return Err(LicenseError::DebuggerDetected);
}
// Check 2: Binary integrity
verify_binary(expected_binary_hash)?;
Ok(())
}
/// Obfuscate a string literal (compile-time)
/// Usage: obfstr!("sensitive_string")
/// In production, use the `obfstr` crate for true compile-time obfuscation
#[macro_export]
macro_rules! obf {
($s:expr) => {{
let bytes: &[u8] = $s.as_bytes();
let mut obfuscated: Vec<u8> = bytes.iter().map(|b| b ^ 0xAA).collect();
let deobf: Vec<u8> = obfuscated.iter().map(|b| b ^ 0xAA).collect();
String::from_utf8(deobf).unwrap_or_default()
}};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_binary_hash_not_empty() {
let hash = current_binary_hash();
assert!(!hash.is_empty());
assert_eq!(hash.len(), 64);
}
#[test]
fn test_obf_macro() {
let original = "license.gyannetra.com";
let result = obf!(original);
assert_eq!(result, original);
}
}

View File

@@ -0,0 +1,130 @@
//! Encrypted License Cache
//!
//! Stores the license JWT locally in an AES-256-GCM encrypted file.
//! This prevents casual reading/modification of the cached license.
use crate::LicenseError;
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use sha2::{Sha256, Digest};
use std::path::PathBuf;
/// Get the path to the license cache file
fn cache_path() -> PathBuf {
let config_dir = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("gyandoot");
config_dir.join("license.enc")
}
/// Derive an AES-256 key from machine fingerprint + a static salt
fn derive_key() -> Vec<u8> {
let fingerprint = crate::fingerprint::collect_fingerprint();
let salt = b"GyAnDoOt_LiCeNsE_v1"; // Static salt, not secret
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(fingerprint.as_bytes());
hasher.finalize().to_vec()
}
/// Save a license JWT to encrypted local cache
pub fn save_license(jwt_token: &str) -> Result<(), LicenseError> {
let key = derive_key();
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| LicenseError::Cache(format!("Key init failed: {}", e)))?;
let nonce_bytes: [u8; 12] = rand::random();
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, jwt_token.as_bytes())
.map_err(|e| LicenseError::Cache(format!("Encryption failed: {}", e)))?;
// Store: 12 bytes nonce + ciphertext
let mut data = nonce_bytes.to_vec();
data.extend_from_slice(&ciphertext);
let path = cache_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LicenseError::Cache(format!("Failed to create cache dir: {}", e)))?;
}
// Set restrictive permissions (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(file) = std::fs::File::create(&path) {
let mut perms = file.metadata().map(|m| m.permissions()).unwrap_or_else(|_| {
std::fs::Permissions::from_mode(0o600)
});
perms.set_mode(0o600);
let _ = file.set_permissions(perms);
}
}
std::fs::write(&path, &data)
.map_err(|e| LicenseError::Cache(format!("Failed to write cache: {}", e)))?;
Ok(())
}
/// Load a license JWT from encrypted local cache
pub fn load_license() -> Result<String, LicenseError> {
let path = cache_path();
if !path.exists() {
return Err(LicenseError::Cache("No cached license found".to_string()));
}
let data = std::fs::read(&path)
.map_err(|e| LicenseError::Cache(format!("Failed to read cache: {}", e)))?;
if data.len() < 12 {
return Err(LicenseError::Cache("Corrupted cache file".to_string()));
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let key = derive_key();
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|e| LicenseError::Cache(format!("Key init failed: {}", e)))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| LicenseError::Cache("Decryption failed — cache tampered or wrong machine".to_string()))?;
String::from_utf8(plaintext)
.map_err(|e| LicenseError::Cache(format!("Invalid UTF-8 in cache: {}", e)))
}
/// Check if a cached license exists
pub fn has_cached_license() -> bool {
cache_path().exists()
}
/// Clear the cached license
pub fn clear_cache() -> Result<(), LicenseError> {
let path = cache_path();
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| LicenseError::Cache(format!("Failed to clear cache: {}", e)))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_save_and_load_roundtrip() {
let token = "eyJhbGciOiJFZERTQSJ9.test_token";
save_license(token).unwrap();
let loaded = load_license().unwrap();
assert_eq!(loaded, token);
clear_cache().unwrap();
}
}

View File

@@ -0,0 +1,171 @@
//! Machine Fingerprinting
//!
//! Collects hardware identifiers to create a unique machine fingerprint.
//! This prevents license sharing by binding licenses to specific hardware.
use sha2::{Sha256, Digest};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
/// Collected machine identifiers
pub struct MachineId {
pub cpu_id: String,
pub mac_hash: String,
pub disk_serial: String,
pub hostname_hash: String,
pub machine_id: String,
}
/// Collect all available machine identifiers
pub fn collect_ids() -> MachineId {
MachineId {
cpu_id: get_cpu_id(),
mac_hash: get_mac_hash(),
disk_serial: get_disk_serial(),
hostname_hash: get_hostname_hash(),
machine_id: get_machine_id(),
}
}
/// Generate a single fingerprint string from all identifiers
pub fn collect_fingerprint() -> String {
let ids = collect_ids();
let mut hasher = Sha256::new();
hasher.update(ids.cpu_id.as_bytes());
hasher.update(ids.mac_hash.as_bytes());
hasher.update(ids.disk_serial.as_bytes());
hasher.update(ids.hostname_hash.as_bytes());
hasher.update(ids.machine_id.as_bytes());
hex::encode(hasher.finalize())
}
fn get_cpu_id() -> String {
// Try /proc/cpuinfo first (Linux)
if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
for line in content.lines() {
if let Some(val) = line.strip_prefix("model name\t: ") {
return val.trim().to_string();
}
if let Some(val) = line.strip_prefix("processor\t: ") {
return format!("cpu_{}", val.trim());
}
}
}
// Fallback: use a hash of /proc/cpuinfo
if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
return format!("cpu_{}", hex::encode(&hasher.finalize()[..8]));
}
"unknown_cpu".to_string()
}
fn get_mac_hash() -> String {
// Try common network interface paths
let paths = [
"/sys/class/net/eth0/address",
"/sys/class/net/enp0s3/address",
"/sys/class/net/enp0s8/address",
"/sys/class/net/wlan0/address",
];
for path in &paths {
if let Ok(content) = std::fs::read_to_string(path) {
let mac = content.trim().to_lowercase();
if !mac.is_empty() && mac != "00:00:00:00:00:00" {
let mut hasher = Sha256::new();
hasher.update(mac.as_bytes());
return hex::encode(&hasher.finalize()[..8]);
}
}
}
// Fallback: try 'ip link' or 'ifconfig'
if let Ok(output) = std::process::Command::new("sh")
.arg("-c")
.arg("ip link show 2>/dev/null | grep -E 'link/ether' | head -1 | awk '{print $2}'")
.output()
{
let mac = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !mac.is_empty() {
let mut hasher = Sha256::new();
hasher.update(mac.as_bytes());
return hex::encode(&hasher.finalize()[..8]);
}
}
"unknown_mac".to_string()
}
fn get_disk_serial() -> String {
// Try /dev/disk/by-id/ (Linux)
if let Ok(entries) = std::fs::read_dir("/dev/disk/by-id/") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.contains("part") && !name.contains("wwn") {
let mut hasher = Sha256::new();
hasher.update(name.as_bytes());
return hex::encode(&hasher.finalize()[..8]);
}
}
}
// Fallback: use root filesystem UUID
if let Ok(content) = std::fs::read_to_string("/etc/fstab") {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
return hex::encode(&hasher.finalize()[..8]);
}
"unknown_disk".to_string()
}
fn get_hostname_hash() -> String {
let hostname = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| {
std::fs::read_to_string("/etc/hostname")
.unwrap_or_else(|_| "unknown".to_string())
});
let mut hasher = Sha256::new();
hasher.update(hostname.trim().as_bytes());
hex::encode(&hasher.finalize()[..8])
}
fn get_machine_id() -> String {
// Try /etc/machine-id (systemd systems)
if let Ok(content) = std::fs::read_to_string("/etc/machine-id") {
return content.trim().to_string();
}
// Try /var/lib/dbus/machine-id
if let Ok(content) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
return content.trim().to_string();
}
"unknown_machine".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fingerprint_is_deterministic() {
let fp1 = collect_fingerprint();
let fp2 = collect_fingerprint();
assert_eq!(fp1, fp2, "Fingerprint should be deterministic within same session");
}
#[test]
fn test_fingerprint_length() {
let fp = collect_fingerprint();
assert_eq!(fp.len(), 64, "SHA-256 fingerprint should be 64 hex chars");
}
#[test]
fn test_collect_ids_no_panic() {
let ids = collect_ids();
assert!(!ids.cpu_id.is_empty());
assert!(!ids.mac_hash.is_empty());
assert!(!ids.hostname_hash.is_empty());
}
}

View File

@@ -0,0 +1,101 @@
//! JWT License Token Management
//!
//! Handles signing, verifying, and parsing license JWTs using Ed25519.
use crate::{LicenseClaims, LicenseError};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation, Algorithm};
/// Sign a license claims into a JWT (server-side)
pub fn sign_jwt(claims: &LicenseClaims, private_key_pem: &[u8]) -> Result<String, LicenseError> {
let key = EncodingKey::from_ed_pem(private_key_pem)
.map_err(|e| LicenseError::Other(format!("Invalid private key PEM: {}", e)))?;
let header = Header::new(Algorithm::EdDSA);
encode(&header, claims, &key)
.map_err(|e| LicenseError::Other(format!("JWT signing failed: {}", e)))
}
/// Verify a JWT and extract claims (client-side)
pub fn verify_jwt(token: &str, public_key_bytes: &[u8]) -> Result<LicenseClaims, LicenseError> {
let key = DecodingKey::from_ed_der(public_key_bytes);
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_issuer(&["gyandoot.gyannetra.com"]);
validation.validate_exp = true;
let token_data = decode::<LicenseClaims>(token, &key, &validation)
.map_err(|e| {
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
LicenseError::Expired("JWT has expired".to_string())
}
jsonwebtoken::errors::ErrorKind::InvalidSignature => {
LicenseError::InvalidSignature
}
_ => LicenseError::Other(format!("JWT verification failed: {}", e)),
}
})?;
Ok(token_data.claims)
}
/// Generate a new Ed25519 key pair (for initial setup)
pub fn generate_keypair() -> (Vec<u8>, Vec<u8>) {
use ed25519_dalek::{SigningKey, VerifyingKey, SecretKey};
use rand::rngs::OsRng;
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
(signing_key.to_bytes().to_vec(), verifying_key.to_bytes().to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LicenseType;
fn make_test_claims() -> LicenseClaims {
LicenseClaims {
iss: "gyandoot.gyannetra.com".to_string(),
sub: "cust_test123".to_string(),
iat: chrono::Utc::now().timestamp(),
exp: chrono::Utc::now().timestamp() + 86400, // 1 day
license_type: "yearly".to_string(),
max_agents: 5,
features: vec!["browser".to_string(), "telegram".to_string()],
hw_fingerprint: "test_fingerprint".to_string(),
hw_fallback: vec![],
customer_name: "Test Corp".to_string(),
support_level: "standard".to_string(),
}
}
#[test]
fn test_sign_and_verify_jwt() {
let (private_key, public_key) = generate_keypair();
let claims = make_test_claims();
// Sign
let pem = generate_pem_from_private(&private_key);
let token = sign_jwt(&claims, pem.as_bytes()).unwrap();
assert!(!token.is_empty());
// Verify
let verified = verify_jwt(&token, &public_key).unwrap();
assert_eq!(verified.sub, "cust_test123");
assert_eq!(verified.max_agents, 5);
}
fn generate_pem_from_private(private_bytes: &[u8]) -> String {
// Simple PEM wrapper for Ed25519 private key
let b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
private_bytes
);
format!(
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----",
b64
)
}
}

View File

@@ -0,0 +1,207 @@
//! GyanDoot License Validation Crate
//!
//! This crate provides:
//! - Machine fingerprinting (CPU, MAC, Disk, TPM)
//! - JWT-based license validation (Ed25519)
//! - Agent counting and enforcement
//! - Encrypted local license cache
//! - Anti-tamper binary integrity checks
//! - Debugger detection
pub mod fingerprint;
pub mod jwt;
pub mod agent_counter;
pub mod cache;
pub mod anti_tamper;
use serde::{Deserialize, Serialize};
/// Supported license types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LicenseType {
/// 1-year renewable subscription
Yearly,
/// 10-year non-expiring license
Lifetime,
}
impl std::fmt::Display for LicenseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LicenseType::Yearly => write!(f, "yearly"),
LicenseType::Lifetime => write!(f, "lifetime"),
}
}
}
/// Support level
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SupportLevel {
Standard,
Premium,
}
/// The license payload embedded in the JWT
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseClaims {
// Standard JWT claims
pub iss: String,
pub sub: String,
pub iat: i64,
pub exp: i64,
// GyanDoot-specific
pub license_type: String,
pub max_agents: u32,
pub features: Vec<String>,
pub hw_fingerprint: String,
pub hw_fallback: Vec<String>,
pub customer_name: String,
pub support_level: String,
}
/// Errors from the license system
#[derive(Debug, thiserror::Error)]
pub enum LicenseError {
#[error("License expired: {0}")]
Expired(String),
#[error("Invalid license signature")]
InvalidSignature,
#[error("Machine fingerprint mismatch — license bound to different machine")]
MachineMismatch,
#[error("Max agents ({max}) exceeded — currently {current} active")]
MaxAgentsExceeded { max: u32, current: u32 },
#[error("Feature '{0}' not included in license")]
FeatureNotLicensed(String),
#[error("Binary integrity check failed — binary has been modified")]
BinaryTampered,
#[error("Debugger detected — refusing to run")]
DebuggerDetected,
#[error("Network error: {0}")]
Network(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("{0}")]
Other(String),
}
/// Result of a license validation
#[derive(Debug)]
pub enum ValidationResult {
Valid(LicenseClaims),
Expired,
Invalid,
MachineMismatch,
}
/// The main license validator
pub struct LicenseValidator {
/// Ed25519 public key (hex-encoded) embedded in binary
public_key: Vec<u8>,
/// License server URL
server_url: String,
/// Current machine fingerprint
fingerprint: String,
}
impl LicenseValidator {
/// Create a new validator with the embedded public key
pub fn new(public_key_hex: &str, server_url: &str) -> Self {
let public_key = hex::decode(public_key_hex)
.expect("Invalid embedded public key — this is a build-time constant");
let fingerprint = fingerprint::collect_fingerprint();
Self {
public_key,
server_url: server_url.to_string(),
fingerprint,
}
}
/// Validate a license JWT locally (no network call)
pub fn validate_local(&self, jwt_token: &str) -> Result<LicenseClaims, LicenseError> {
let claims = jwt::verify_jwt(jwt_token, &self.public_key)?;
// Check expiry
let now = chrono::Utc::now().timestamp();
if now > claims.exp {
return Err(LicenseError::Expired(format!(
"License expired at {}",
chrono::DateTime::from_timestamp(claims.exp, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| "unknown".to_string())
)));
}
// Check machine binding
if claims.hw_fingerprint != self.fingerprint {
// Check fallback machines
if !claims.hw_fallback.contains(&self.fingerprint) {
return Err(LicenseError::MachineMismatch);
}
}
Ok(claims)
}
/// Activate a license key with the server
pub async fn activate(&self, license_key: &str) -> Result<LicenseClaims, LicenseError> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/api/v1/activate", self.server_url))
.json(&serde_json::json!({
"license_key": license_key,
"hw_fingerprint": self.fingerprint,
"binary_hash": anti_tamper::current_binary_hash(),
}))
.send()
.await
.map_err(|e| LicenseError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(LicenseError::Other(format!(
"Activation failed ({}): {}",
status, body
)));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| LicenseError::Other(e.to_string()))?;
let jwt_token = body["jwt"]
.as_str()
.ok_or_else(|| LicenseError::Other("No JWT in response".to_string()))?;
let claims = self.validate_local(jwt_token)?;
// Cache the license locally
cache::save_license(jwt_token)?;
Ok(claims)
}
/// Check if a feature is licensed
pub fn check_feature(&self, claims: &LicenseClaims, feature: &str) -> Result<(), LicenseError> {
if !claims.features.iter().any(|f| f == feature) {
return Err(LicenseError::FeatureNotLicensed(feature.to_string()));
}
Ok(())
}
/// Get the machine fingerprint (for display/debug)
pub fn get_fingerprint(&self) -> &str {
&self.fingerprint
}
}