// License validation, storage and reading for the Base/Premium editions. // // Architecture: // - License key = "SR-BASE-" or "SR-PREMIUM-", JWT signed Ed25519 by the server // - Activation token = separate JWT, also signed by the server, binds the license to a machine // (machine_id claim must match the local machine_id). Without it, a copied license.key would // work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49). // - Both files live in app_data_dir/ — license.key and activation.token // - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND // either there is no activation token (graceful pre-activation state) OR the activation token // matches the local machine_id. // // CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it. use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use tauri::Manager; use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM}; // Ed25519 public key for license verification. // // Production key generated 2026-04-10. The corresponding private key lives ONLY // on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM. const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\ -----END PUBLIC KEY-----\n"; const LICENSE_FILE: &str = "license.key"; const ACTIVATION_FILE: &str = "activation.token"; const KEY_PREFIX_BASE: &str = "SR-BASE-"; const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-"; /// Decoded license metadata exposed to the frontend. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LicenseInfo { pub edition: String, pub email: String, pub features: Vec, pub machine_limit: u32, pub issued_at: i64, pub expires_at: i64, } /// Claims embedded in the license JWT (signed by the license server). #[derive(Debug, Clone, Serialize, Deserialize)] struct LicenseClaims { sub: String, // email iss: String, iat: i64, exp: i64, // mandatory — see CWE-613 edition: String, #[serde(default)] features: Vec, machine_limit: u32, } /// Claims embedded in the activation token JWT (server-signed, machine-bound). #[derive(Debug, Clone, Serialize, Deserialize)] struct ActivationClaims { sub: String, // license id or hash iat: i64, exp: i64, machine_id: String, } fn app_data_dir(app: &tauri::AppHandle) -> Result { app.path() .app_data_dir() .map_err(|e| format!("Cannot get app data dir: {}", e)) } fn license_path(app: &tauri::AppHandle) -> Result { Ok(app_data_dir(app)?.join(LICENSE_FILE)) } fn activation_path(app: &tauri::AppHandle) -> Result { Ok(app_data_dir(app)?.join(ACTIVATION_FILE)) } /// Strip the human-readable prefix and return the bare JWT. fn strip_prefix(key: &str) -> Result<&str, String> { let trimmed = key.trim(); if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) { return Ok(jwt); } if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) { return Ok(jwt); } Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string()) } /// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future /// config change cannot silently disable expiry checking (CWE-613). fn strict_validation() -> Validation { let mut validation = Validation::new(Algorithm::EdDSA); validation.validate_exp = true; validation.leeway = 0; validation.set_required_spec_claims(&["exp", "iat"]); validation } /// Build the production `DecodingKey` from the embedded PEM constant. fn embedded_decoding_key() -> Result { DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes()) .map_err(|e| format!("Invalid public key: {}", e)) } /// Pure validation: decode the JWT, verify signature with the provided key, ensure the /// edition claim is one we recognize. Returns `LicenseInfo` on success. /// /// Separated from the Tauri command so tests can pass their own key. fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result { let jwt = strip_prefix(key)?; let validation = strict_validation(); let data = decode::(jwt, decoding_key, &validation) .map_err(|e| format!("Invalid license: {}", e))?; let claims = data.claims; if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM { return Err(format!("Unknown edition '{}'", claims.edition)); } Ok(LicenseInfo { edition: claims.edition, email: claims.sub, features: claims.features, machine_limit: claims.machine_limit, issued_at: claims.iat, expires_at: claims.exp, }) } /// Validate an activation token against the local machine. The token must be signed by the /// license server and its `machine_id` claim must match the local machine identifier. fn validate_activation_with_key( token: &str, local_machine_id: &str, decoding_key: &DecodingKey, ) -> Result<(), String> { let validation = strict_validation(); let data = decode::(token.trim(), decoding_key, &validation) .map_err(|e| format!("Invalid activation token: {}", e))?; if data.claims.machine_id != local_machine_id { return Err("Activation token belongs to a different machine".to_string()); } Ok(()) } // === Tauri commands =========================================================================== /// Validate a license key without persisting it. Used by the UI to give immediate feedback /// before the user confirms storage. #[tauri::command] pub fn validate_license_key(key: String) -> Result { let decoding_key = embedded_decoding_key()?; validate_with_key(&key, &decoding_key) } /// Persist a previously-validated license key to disk. The activation token (machine binding) /// is stored separately by [`store_activation_token`] once the server has issued one. #[tauri::command] pub fn store_license(app: tauri::AppHandle, key: String) -> Result { let decoding_key = embedded_decoding_key()?; let info = validate_with_key(&key, &decoding_key)?; let path = license_path(&app)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?; } fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?; Ok(info) } /// Persist a server-issued activation token (machine binding). The token is opaque to the /// caller — it must validate against the local machine_id to be considered active. #[tauri::command] pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> { let local_id = machine_id_internal()?; let decoding_key = embedded_decoding_key()?; validate_activation_with_key(&token, &local_id, &decoding_key)?; let path = activation_path(&app)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?; } fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e)) } /// Read the stored license without revalidating. Returns `None` when no license is present. /// The returned info is only structurally decoded — call [`get_edition`] for the gating value. #[tauri::command] pub fn read_license(app: tauri::AppHandle) -> Result, String> { let path = license_path(&app)?; if !path.exists() { return Ok(None); } let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?; let Ok(decoding_key) = embedded_decoding_key() else { return Ok(None); }; Ok(validate_with_key(&key, &decoding_key).ok()) } /// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates. /// /// Returns "free" when: /// - no license is stored, /// - the license JWT is invalid or expired, /// - an activation token exists but does not match this machine. /// /// Note: a missing activation token is treated as a graceful pre-activation state and does /// NOT downgrade the edition. Server-side activation happens later (Issue #53). #[tauri::command] pub fn get_edition(app: tauri::AppHandle) -> Result { Ok(current_edition(&app)) } /// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any /// failure resolves to "free" so feature gates fail closed. /// /// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free. pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { // Check Compte Maximus subscription first — Premium overrides Base if let Some(edition) = check_account_edition(app) { if edition == EDITION_PREMIUM { return edition; } } let Ok(path) = license_path(app) else { return EDITION_FREE.to_string(); }; if !path.exists() { return EDITION_FREE.to_string(); } let Ok(key) = fs::read_to_string(&path) else { return EDITION_FREE.to_string(); }; let Ok(decoding_key) = embedded_decoding_key() else { return EDITION_FREE.to_string(); }; let Ok(info) = validate_with_key(&key, &decoding_key) else { return EDITION_FREE.to_string(); }; // If an activation token exists, it must match the local machine. A missing token is // accepted (graceful pre-activation). if let Ok(activation_path) = activation_path(app) { if activation_path.exists() { let Ok(token) = fs::read_to_string(&activation_path) else { return EDITION_FREE.to_string(); }; let Ok(local_id) = machine_id_internal() else { return EDITION_FREE.to_string(); }; if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() { return EDITION_FREE.to_string(); } } } info.edition } /// Read the cached account.json to check for an active Premium subscription. /// Returns None if no account file exists or the file is invalid. fn check_account_edition(app: &tauri::AppHandle) -> Option { let dir = app_data_dir(app).ok()?.join("auth"); let account_path = dir.join("account.json"); if !account_path.exists() { return None; } let raw = fs::read_to_string(&account_path).ok()?; let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?; match account.subscription_status.as_deref() { Some("active") => Some(EDITION_PREMIUM.to_string()), _ => None, } } /// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall /// or hardware migration, in which case the user must re-activate (handled in Issue #53). #[tauri::command] pub fn get_machine_id() -> Result { machine_id_internal() } fn machine_id_internal() -> Result { machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) } // License server API base URL. Overridable via SIMPL_API_URL env var for development. fn api_base_url() -> String { std::env::var("SIMPL_API_URL") .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) } /// Machine info returned by the license server. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MachineInfo { pub machine_id: String, pub machine_name: Option, pub activated_at: String, pub last_seen_at: String, } /// Activation status for display in the UI. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActivationStatus { pub is_activated: bool, pub machine_id: String, } /// Activate this machine with the license server. Reads the stored license key, sends /// the machine_id to the API, and stores the returned activation token. #[tauri::command] pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> { let key_path = license_path(&app)?; if !key_path.exists() { return Err("No license key stored".to_string()); } let license_key = fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; let machine_id = machine_id_internal()?; let machine_name = hostname::get() .ok() .and_then(|h| h.into_string().ok()); let url = format!("{}/licenses/activate", api_base_url()); let client = reqwest::Client::new(); let mut body = serde_json::json!({ "license_key": license_key.trim(), "machine_id": machine_id, }); if let Some(name) = machine_name { body["machine_name"] = serde_json::Value::String(name); } let resp = client .post(&url) .json(&body) .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|e| format!("Cannot reach license server: {}", e))?; let status = resp.status(); let resp_body: serde_json::Value = resp .json() .await .map_err(|e| format!("Invalid server response: {}", e))?; if !status.is_success() { let error = resp_body["error"] .as_str() .unwrap_or("Activation failed"); return Err(error.to_string()); } let token = resp_body["activation_token"] .as_str() .ok_or("Server did not return an activation token")?; // store_activation_token validates the token against local machine_id before writing store_activation_token(app, token.to_string())?; Ok(()) } /// Deactivate a machine on the license server, freeing a slot. #[tauri::command] pub async fn deactivate_machine( app: tauri::AppHandle, machine_id: String, ) -> Result<(), String> { let key_path = license_path(&app)?; if !key_path.exists() { return Err("No license key stored".to_string()); } let license_key = fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; let url = format!("{}/licenses/deactivate", api_base_url()); let client = reqwest::Client::new(); let resp = client .post(&url) .json(&serde_json::json!({ "license_key": license_key.trim(), "machine_id": machine_id, })) .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|e| format!("Cannot reach license server: {}", e))?; let status = resp.status(); if !status.is_success() { let resp_body: serde_json::Value = resp .json() .await .unwrap_or(serde_json::json!({"error": "Deactivation failed"})); let error = resp_body["error"].as_str().unwrap_or("Deactivation failed"); return Err(error.to_string()); } // If deactivating this machine, remove the local activation token let local_id = machine_id_internal()?; if machine_id == local_id { let act_path = activation_path(&app)?; if act_path.exists() { let _ = fs::remove_file(&act_path); } } Ok(()) } /// List all machines currently activated for the stored license. #[tauri::command] pub async fn list_activated_machines(app: tauri::AppHandle) -> Result, String> { let key_path = license_path(&app)?; if !key_path.exists() { return Ok(vec![]); } let license_key = fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?; let url = format!("{}/licenses/verify", api_base_url()); let client = reqwest::Client::new(); let resp = client .post(&url) .json(&serde_json::json!({ "license_key": license_key.trim(), })) .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|e| format!("Cannot reach license server: {}", e))?; if !resp.status().is_success() { return Err("Cannot verify license".to_string()); } let body: serde_json::Value = resp .json() .await .map_err(|e| format!("Invalid server response: {}", e))?; // The verify endpoint returns machines in the response when valid let machines = body["machines"] .as_array() .map(|arr| { arr.iter() .filter_map(|m| serde_json::from_value::(m.clone()).ok()) .collect() }) .unwrap_or_default(); Ok(machines) } /// Check the local activation status without contacting the server. #[tauri::command] pub fn get_activation_status(app: tauri::AppHandle) -> Result { let machine_id = machine_id_internal()?; let act_path = activation_path(&app)?; let is_activated = if act_path.exists() { if let Ok(token) = fs::read_to_string(&act_path) { if let Ok(decoding_key) = embedded_decoding_key() { validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok() } else { false } } else { false } } else { false }; Ok(ActivationStatus { is_activated, machine_id, }) } // === Tests ==================================================================================== #[cfg(test)] mod tests { use super::*; use ed25519_dalek::SigningKey; use jsonwebtoken::{encode, EncodingKey, Header}; // === Manual DER encoder for the Ed25519 private key ======================================= // We avoid the `pem` feature on `ed25519-dalek` because the `LineEnding` re-export path // varies across `pkcs8`/`spki`/`der` versions. The Ed25519 PKCS#8 v1 byte layout is fixed // and trivial: 16-byte prefix + 32-byte raw seed. // // Note the asymmetry in jsonwebtoken's API: // - `EncodingKey::from_ed_der` expects a PKCS#8-wrapped private key (passed to ring's // `Ed25519KeyPair::from_pkcs8`). // - `DecodingKey::from_ed_der` expects the *raw* 32-byte public key (passed to ring's // `UnparsedPublicKey::new` which takes raw bytes, not a SubjectPublicKeyInfo). /// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob. fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec { // SEQUENCE(46) { // INTEGER(1) 0 // version v1 // SEQUENCE(5) { // OID(3) 1.3.101.112 // Ed25519 // } // OCTET STRING(34) { // OCTET STRING(32) <32 bytes> // } // } let mut der = vec![ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]; der.extend_from_slice(seed); der } /// Build a deterministic test keypair so signed tokens are reproducible across runs. fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) { let signing_key = SigningKey::from_bytes(&seed); let pubkey_bytes = signing_key.verifying_key().to_bytes(); let priv_der = ed25519_pkcs8_private_der(&seed); let encoding_key = EncodingKey::from_ed_der(&priv_der); // Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above. let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes); (encoding_key, decoding_key) } fn default_keys() -> (EncodingKey, DecodingKey) { test_keys([42u8; 32]) } fn make_token(encoding_key: &EncodingKey, claims: &T) -> String { encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap() } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64 } #[test] fn rejects_key_without_prefix() { let (_enc, dec) = default_keys(); let result = validate_with_key("nonsense", &dec); assert!(result.is_err()); } #[test] fn accepts_well_formed_base_license() { let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now(), exp: now() + 86400, edition: EDITION_BASE.to_string(), features: vec!["auto-update".to_string()], machine_limit: 3, }; let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let info = validate_with_key(&key, &dec).unwrap(); assert_eq!(info.edition, EDITION_BASE); assert_eq!(info.email, "user@example.com"); assert_eq!(info.machine_limit, 3); } #[test] fn rejects_expired_license() { let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now() - 1000, exp: now() - 100, edition: EDITION_BASE.to_string(), features: vec![], machine_limit: 3, }; let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let result = validate_with_key(&key, &dec); assert!(result.is_err(), "expired license must be rejected"); } #[test] fn rejects_license_signed_with_wrong_key() { let (enc_signer, _dec_signer) = default_keys(); let (_enc_other, dec_other) = test_keys([7u8; 32]); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now(), exp: now() + 86400, edition: EDITION_BASE.to_string(), features: vec![], machine_limit: 3, }; let jwt = make_token(&enc_signer, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let result = validate_with_key(&key, &dec_other); assert!(result.is_err(), "wrong-key signature must be rejected"); } #[test] fn rejects_corrupted_jwt() { let (_enc, dec) = default_keys(); let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE); let result = validate_with_key(&key, &dec); assert!(result.is_err()); } #[test] fn rejects_unknown_edition() { let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now(), exp: now() + 86400, edition: "enterprise".to_string(), features: vec![], machine_limit: 3, }; let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let result = validate_with_key(&key, &dec); assert!(result.is_err()); } #[test] fn activation_token_matches_machine() { let (enc, dec) = default_keys(); let claims = ActivationClaims { sub: "license-id".to_string(), iat: now(), exp: now() + 86400, machine_id: "this-machine".to_string(), }; let token = make_token(&enc, &claims); assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok()); } #[test] fn activation_token_rejects_other_machine() { let (enc, dec) = default_keys(); let claims = ActivationClaims { sub: "license-id".to_string(), iat: now(), exp: now() + 86400, machine_id: "machine-A".to_string(), }; let token = make_token(&enc, &claims); let result = validate_activation_with_key(&token, "machine-B", &dec); assert!(result.is_err(), "copied activation token must be rejected"); } #[test] fn embedded_public_key_pem_parses() { // Sanity check that the production PEM constant is well-formed. assert!(embedded_decoding_key().is_ok()); } }