From 968710dc46ad46fa3ef3f5e25699011ffd449765 Mon Sep 17 00:00:00 2001 From: Ishaan Reddy Date: Sun, 5 Jul 2026 10:14:48 +0530 Subject: [PATCH] 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) --- Cargo.toml | 2 + crates/gyandoot-license/Cargo.toml | 28 +++ crates/gyandoot-license/src/agent_counter.rs | 100 +++++++++ crates/gyandoot-license/src/anti_tamper.rs | 118 +++++++++++ crates/gyandoot-license/src/cache.rs | 130 ++++++++++++ crates/gyandoot-license/src/fingerprint.rs | 171 +++++++++++++++ crates/gyandoot-license/src/jwt.rs | 101 +++++++++ crates/gyandoot-license/src/lib.rs | 207 +++++++++++++++++++ src/main.rs | 116 +++++++++++ 9 files changed, 973 insertions(+) create mode 100644 crates/gyandoot-license/Cargo.toml create mode 100644 crates/gyandoot-license/src/agent_counter.rs create mode 100644 crates/gyandoot-license/src/anti_tamper.rs create mode 100644 crates/gyandoot-license/src/cache.rs create mode 100644 crates/gyandoot-license/src/fingerprint.rs create mode 100644 crates/gyandoot-license/src/jwt.rs create mode 100644 crates/gyandoot-license/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 42df105..c78ce7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/gyandoot-gateway", "crates/gyandoot-browser", "crates/gyandoot-tui", + "crates/gyandoot-license", ] [workspace.package] @@ -73,6 +74,7 @@ gyandoot-profile = { path = "crates/gyandoot-profile" } gyandoot-gateway = { path = "crates/gyandoot-gateway" } gyandoot-browser = { path = "crates/gyandoot-browser" } gyandoot-tui = { path = "crates/gyandoot-tui" } +gyandoot-license = { path = "crates/gyandoot-license" } tokio.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/gyandoot-license/Cargo.toml b/crates/gyandoot-license/Cargo.toml new file mode 100644 index 0000000..2c6841b --- /dev/null +++ b/crates/gyandoot-license/Cargo.toml @@ -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" diff --git a/crates/gyandoot-license/src/agent_counter.rs b/crates/gyandoot-license/src/agent_counter.rs new file mode 100644 index 0000000..4bd6829 --- /dev/null +++ b/crates/gyandoot-license/src/agent_counter.rs @@ -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, +} + +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 { + 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, +} + +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); + } +} diff --git a/crates/gyandoot-license/src/anti_tamper.rs b/crates/gyandoot-license/src/anti_tamper.rs new file mode 100644 index 0000000..0140337 --- /dev/null +++ b/crates/gyandoot-license/src/anti_tamper.rs @@ -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 = bytes.iter().map(|b| b ^ 0xAA).collect(); + let deobf: Vec = 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); + } +} diff --git a/crates/gyandoot-license/src/cache.rs b/crates/gyandoot-license/src/cache.rs new file mode 100644 index 0000000..872b336 --- /dev/null +++ b/crates/gyandoot-license/src/cache.rs @@ -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 { + 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 { + 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(); + } +} diff --git a/crates/gyandoot-license/src/fingerprint.rs b/crates/gyandoot-license/src/fingerprint.rs new file mode 100644 index 0000000..7d7dd7d --- /dev/null +++ b/crates/gyandoot-license/src/fingerprint.rs @@ -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()); + } +} diff --git a/crates/gyandoot-license/src/jwt.rs b/crates/gyandoot-license/src/jwt.rs new file mode 100644 index 0000000..31f138c --- /dev/null +++ b/crates/gyandoot-license/src/jwt.rs @@ -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 { + 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 { + 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::(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, Vec) { + 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 + ) + } +} diff --git a/crates/gyandoot-license/src/lib.rs b/crates/gyandoot-license/src/lib.rs new file mode 100644 index 0000000..643515a --- /dev/null +++ b/crates/gyandoot-license/src/lib.rs @@ -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, + pub hw_fingerprint: String, + pub hw_fallback: Vec, + 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, + /// 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 { + 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 { + 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 + } +} diff --git a/src/main.rs b/src/main.rs index f43d2d6..d6e2837 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,12 @@ enum Commands { #[command(subcommand)] action: PlatformAction, }, + /// License management (activate, status, deactivate, fingerprint) + #[command(name = "license")] + License { + #[command(subcommand)] + action: LicenseAction, + }, } #[derive(Subcommand, Debug)] @@ -145,6 +151,25 @@ enum PlatformAction { Configure, } +#[derive(Subcommand, Debug)] +enum LicenseAction { + /// Activate GyanDoot with a license key + Activate { + /// License key (e.g., GYANDOOT-XXXX-XXXX) + #[arg(short, long)] + key: String, + /// License server URL + #[arg(short, long, default_value = "https://license.gyannetra.com")] + server: String, + }, + /// Show current license status + Status, + /// Deactivate license on this machine + Deactivate, + /// Show machine fingerprint (for support) + Fingerprint, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -196,6 +221,9 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Platform { action }) => { cmd_platform(action).await?; } + Some(Commands::License { action }) => { + cmd_license(action).await?; + } None => { // Default: run interactive CLI mode cmd_run(&cli.profile, "cli", None).await?; @@ -753,3 +781,91 @@ async fn cmd_platform(action: PlatformAction) -> anyhow::Result<()> { } Ok(()) } + +/// Handle license management subcommands +async fn cmd_license(action: LicenseAction) -> anyhow::Result<()> { + match action { + LicenseAction::Activate { key, server } => { + println!("🔑 Activating GyanDoot license..."); + println!(" Server: {}", server); + println!(" Key: {}...{}", &key[..20], &key[key.len()-8..]); + + let validator = gyandoot_license::LicenseValidator::new( + // Public key will be embedded at build time + "0000000000000000000000000000000000000000000000000000000000000000", + &server, + ); + + match validator.activate(&key).await { + Ok(claims) => { + println!("\n✅ License activated successfully!"); + println!(" Customer: {}", claims.customer_name); + println!(" License Type: {}", claims.license_type); + println!(" Max Agents: {}", claims.max_agents); + println!(" Features: {}", claims.features.join(", ")); + println!(" Expires: {}", chrono::DateTime::from_timestamp(claims.exp, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_else(|| "unknown".to_string())); + } + Err(e) => { + eprintln!("\n❌ License activation failed: {}", e); + std::process::exit(1); + } + } + } + LicenseAction::Status => { + match gyandoot_license::cache::load_license() { + Ok(jwt) => { + let validator = gyandoot_license::LicenseValidator::new( + "0000000000000000000000000000000000000000000000000000000000000000", + "https://license.gyannetra.com", + ); + match validator.validate_local(&jwt) { + Ok(claims) => { + println!("📋 License Status: Active"); + println!(" Customer: {}", claims.customer_name); + println!(" Type: {}", claims.license_type); + println!(" Max Agents: {}", claims.max_agents); + println!(" Features: {}", claims.features.join(", ")); + println!(" Expires: {}", chrono::DateTime::from_timestamp(claims.exp, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_else(|| "unknown".to_string())); + println!(" Machine: {}", &claims.hw_fingerprint[..16]); + } + Err(gyandoot_license::LicenseError::Expired(msg)) => { + println!("❌ License Expired: {}", msg); + } + Err(gyandoot_license::LicenseError::MachineMismatch) => { + println!("❌ License bound to a different machine"); + println!(" Run 'gyandoot license activate' to re-activate"); + } + Err(e) => { + println!("❌ License invalid: {}", e); + } + } + } + Err(_) => { + println!("❌ No active license found"); + println!(" Run 'gyandoot license activate --key YOUR_KEY' to activate"); + } + } + } + LicenseAction::Deactivate => { + match gyandoot_license::cache::clear_cache() { + Ok(()) => { + println!("✅ License deactivated on this machine"); + } + Err(e) => { + eprintln!("❌ Failed to deactivate: {}", e); + } + } + } + LicenseAction::Fingerprint => { + let fp = gyandoot_license::fingerprint::collect_fingerprint(); + println!("🔍 Machine Fingerprint"); + println!(" {}", fp); + println!("\n Send this to support if you need to move your license."); + } + } + Ok(()) +}