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:
28
crates/gyandoot-license/Cargo.toml
Normal file
28
crates/gyandoot-license/Cargo.toml
Normal 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"
|
||||
100
crates/gyandoot-license/src/agent_counter.rs
Normal file
100
crates/gyandoot-license/src/agent_counter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
crates/gyandoot-license/src/anti_tamper.rs
Normal file
118
crates/gyandoot-license/src/anti_tamper.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
130
crates/gyandoot-license/src/cache.rs
Normal file
130
crates/gyandoot-license/src/cache.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
171
crates/gyandoot-license/src/fingerprint.rs
Normal file
171
crates/gyandoot-license/src/fingerprint.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
101
crates/gyandoot-license/src/jwt.rs
Normal file
101
crates/gyandoot-license/src/jwt.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
207
crates/gyandoot-license/src/lib.rs
Normal file
207
crates/gyandoot-license/src/lib.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user