use base64::engine::general_purpose::STANDARD_NO_PAD as BASE64; use base64::Engine; use ed25519_dalek::{Signature, VerifyingKey}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; pub const TALKEDIT_PUBLIC_KEY: [u8; 32] = [123, 66, 135, 187, 184, 19, 205, 192, 129, 94, 91, 138, 123, 3, 72, 52, 229, 8, 245, 226, 187, 218, 67, 131, 38, 214, 239, 203, 206, 7, 132, 176]; pub const TRIAL_DURATION_SECS: u64 = 7 * 86400; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct LicensePayload { pub license_id: String, pub customer_email: String, pub tier: String, pub features: Vec, pub issued_at: u64, pub expires_at: u64, pub max_activations: u32, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TrialState { pub started_at: u64, } /// On-disk format with integrity seed to deter tampering. #[derive(Debug, Serialize, Deserialize)] struct TrialFile { started_at: u64, seed: u64, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "tag")] pub enum AppStatus { Licensed { license: LicensePayload }, Trial { days_remaining: u32, started_at: u64 }, Expired, } #[derive(Debug)] pub enum LicenseError { InvalidFormat, InvalidSignature, Expired, DecodeError(String), CryptoError(String), } impl std::fmt::Display for LicenseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidFormat => write!(f, "Invalid license key format"), Self::InvalidSignature => write!(f, "License signature is invalid"), Self::Expired => write!(f, "License has expired"), Self::DecodeError(e) => write!(f, "License decode error: {e}"), Self::CryptoError(e) => write!(f, "Crypto error: {e}"), } } } // --- License key verification --- pub fn verify_license_key(license_key: &str) -> Result { let stripped = license_key .strip_prefix("talkedit_v1_") .ok_or(LicenseError::InvalidFormat)?; let dot_pos = stripped.rfind('.').ok_or(LicenseError::InvalidFormat)?; let payload_b64 = &stripped[..dot_pos]; let sig_b64 = &stripped[dot_pos + 1..]; if payload_b64.is_empty() || sig_b64.is_empty() { return Err(LicenseError::InvalidFormat); } let payload_bytes = BASE64 .decode(payload_b64) .map_err(|e| LicenseError::DecodeError(e.to_string()))?; let sig_bytes = BASE64 .decode(sig_b64) .map_err(|e| LicenseError::DecodeError(e.to_string()))?; let verifying_key = VerifyingKey::from_bytes(&TALKEDIT_PUBLIC_KEY) .map_err(|e| LicenseError::CryptoError(e.to_string()))?; let signature = Signature::from_slice(&sig_bytes) .map_err(|e| LicenseError::DecodeError(e.to_string()))?; verifying_key .verify_strict(&payload_bytes, &signature) .map_err(|_| LicenseError::InvalidSignature)?; let payload: LicensePayload = serde_json::from_slice(&payload_bytes) .map_err(|e| LicenseError::DecodeError(e.to_string()))?; let now = now_secs(); if now > payload.expires_at { return Err(LicenseError::Expired); } Ok(payload) } // --- License file I/O --- fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } pub fn license_file_path(app_data_dir: &PathBuf) -> PathBuf { app_data_dir.join("license.key") } pub fn trial_file_path(app_data_dir: &PathBuf) -> PathBuf { app_data_dir.join("trial.json") } pub fn trial_sentinel_path(app_data_dir: &PathBuf) -> PathBuf { app_data_dir.join(".trial_lock") } // Simple integrity check constant — not crypto-grade, but deters casual editing. const TRIAL_SEED: u64 = 0x9F3A_2E7D_C1B8_5604; pub fn get_or_start_trial(app_data_dir: &PathBuf) -> TrialState { let path = trial_file_path(app_data_dir); let sentinel = trial_sentinel_path(app_data_dir); let now = now_secs(); // If sentinel exists but trial was deleted, refuse to create a new one. let sentinel_exists = sentinel.exists(); if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(wrapped) = serde_json::from_str::(&content) { // Verify integrity if (wrapped.started_at ^ TRIAL_SEED) == wrapped.seed { return TrialState { started_at: wrapped.started_at }; } } } if sentinel_exists { // Trial was tampered with — return an expired trial return TrialState { started_at: 0 }; } // Start new trial let trial = TrialState { started_at: now }; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&sentinel, "1"); let _ = std::fs::write( &path, serde_json::to_string(&TrialFile { started_at: trial.started_at, seed: trial.started_at ^ TRIAL_SEED, }) .unwrap(), ); trial } pub fn load_cached_license(app_data_dir: &PathBuf) -> Result { let path = license_file_path(app_data_dir); let content = std::fs::read_to_string(&path).map_err(|_| LicenseError::InvalidFormat)?; verify_license_key(content.trim()) } pub fn cache_license(app_data_dir: &PathBuf, license_key: &str) -> Result<(), LicenseError> { let path = license_file_path(app_data_dir); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| LicenseError::DecodeError(e.to_string()))?; } std::fs::write(&path, license_key) .map_err(|e| LicenseError::DecodeError(e.to_string())) } pub fn remove_license(app_data_dir: &PathBuf) { let path = license_file_path(app_data_dir); let _ = std::fs::remove_file(&path); } // --- Trial logic --- pub fn get_trial_info(trial: &TrialState) -> (u64, u64, bool) { let now = now_secs(); let elapsed = now.saturating_sub(trial.started_at); let remaining = TRIAL_DURATION_SECS.saturating_sub(elapsed); let days_remaining = remaining / 86400; let is_active = remaining > 0; (days_remaining, remaining, is_active) } /// Check if trial is still valid and return remaining days. pub fn get_trial_days_remaining(trial: &TrialState) -> Option { let (days, _, active) = get_trial_info(trial); if active { Some(days as u32) } else { None } } pub fn clear_trial(app_data_dir: &PathBuf) { let _ = std::fs::remove_file(trial_file_path(app_data_dir)); let _ = std::fs::remove_file(trial_sentinel_path(app_data_dir)); } /// Get the overall app status: Licensed > Trial > Expired. pub fn get_app_status(app_data_dir: &PathBuf) -> AppStatus { // 1. Check for valid license if let Ok(license) = load_cached_license(app_data_dir) { return AppStatus::Licensed { license }; } // 2. Check for active trial let trial = get_or_start_trial(app_data_dir); if let Some(days) = get_trial_days_remaining(&trial) { return AppStatus::Trial { days_remaining: days, started_at: trial.started_at, }; } // 3. Everything expired AppStatus::Expired } pub fn has_feature(payload: &LicensePayload, feature: &str) -> bool { payload.features.iter().any(|f| f == feature) } #[cfg(test)] mod tests { use super::*; const TEST_KEY: &str = "talkedit_v1_eyJsaWNlbnNlX2lkIjoidGVzdF8wMDEiLCJjdXN0b21lcl9lbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJ0aWVyIjoicHJvIiwiZmVhdHVyZXMiOlsiYnVuZGxlZF9kZXBzIiwiYXV0b191cGRhdGVzIiwiYmdfcmVtb3ZhbCJdLCJpc3N1ZWRfYXQiOjE3NzgwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDk1MzYwMDAsIm1heF9hY3RpdmF0aW9ucyI6M30.vnT0xa8GqINY5ZvU5cCulmlwiS6IUr+Ln5yVzgY+KmGfAlfhiiOpQIsFNiwaG0rb8qk5//7CWFDe6kQdEFnnBw"; #[test] fn test_verify_valid_license() { let result = verify_license_key(TEST_KEY); assert!(result.is_ok(), "Expected OK, got: {:?}", result); let payload = result.unwrap(); assert_eq!(payload.customer_email, "test@example.com"); assert_eq!(payload.tier, "pro"); assert!(payload.features.contains(&"bg_removal".to_string())); } #[test] fn test_verify_invalid_signature() { let dot = TEST_KEY.rfind('.').unwrap(); let prefix = &TEST_KEY[..dot + 1]; let bad_sig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; let tampered = format!("{prefix}{bad_sig}"); let result = verify_license_key(&tampered); assert!(matches!(result, Err(LicenseError::InvalidSignature))); } #[test] fn test_verify_bad_format() { let result = verify_license_key("not-a-license-key"); assert!(matches!(result, Err(LicenseError::InvalidFormat))); } #[test] fn test_has_feature() { let payload = verify_license_key(TEST_KEY).unwrap(); assert!(has_feature(&payload, "bg_removal")); assert!(!has_feature(&payload, "nonexistent_feature")); } #[test] fn test_trial_dates() { let trial = TrialState { started_at: now_secs() }; let days = get_trial_days_remaining(&trial); assert!(days.is_some()); assert_eq!(days.unwrap(), 7); } #[test] fn test_expired_trial() { let trial = TrialState { started_at: 0 }; // epoch = 1970, definitely expired let days = get_trial_days_remaining(&trial); assert!(days.is_none()); } }