2026-05-06 01:35:42 -06:00
|
|
|
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];
|
|
|
|
|
|
2026-05-06 16:05:04 -06:00
|
|
|
pub const TRIAL_DURATION_SECS: u64 = 7 * 86400;
|
2026-05-06 01:35:42 -06:00
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
|
|
|
pub struct LicensePayload {
|
|
|
|
|
pub license_id: String,
|
|
|
|
|
pub customer_email: String,
|
|
|
|
|
pub tier: String,
|
|
|
|
|
pub features: Vec<String>,
|
|
|
|
|
pub issued_at: u64,
|
|
|
|
|
pub expires_at: u64,
|
|
|
|
|
pub max_activations: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
|
|
|
pub struct TrialState {
|
|
|
|
|
pub started_at: u64,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 16:05:04 -06:00
|
|
|
/// On-disk format with integrity seed to deter tampering.
|
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
|
|
|
struct TrialFile {
|
|
|
|
|
started_at: u64,
|
|
|
|
|
seed: u64,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 01:35:42 -06:00
|
|
|
#[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<LicensePayload, LicenseError> {
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 16:05:04 -06:00
|
|
|
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::<TrialFile>(&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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 01:35:42 -06:00
|
|
|
pub fn load_cached_license(app_data_dir: &PathBuf) -> Result<LicensePayload, LicenseError> {
|
|
|
|
|
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<u32> {
|
|
|
|
|
let (days, _, active) = get_trial_info(trial);
|
|
|
|
|
if active {
|
|
|
|
|
Some(days as u32)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn clear_trial(app_data_dir: &PathBuf) {
|
2026-05-06 16:05:04 -06:00
|
|
|
let _ = std::fs::remove_file(trial_file_path(app_data_dir));
|
|
|
|
|
let _ = std::fs::remove_file(trial_sentinel_path(app_data_dir));
|
2026-05-06 01:35:42 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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());
|
2026-05-06 16:05:04 -06:00
|
|
|
assert_eq!(days.unwrap(), 7);
|
2026-05-06 01:35:42 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
}
|